Как разработать игру для android
Перейти к содержимому

Как разработать игру для android

  • автор:

Туториал: Создание простейшей 2D игры на андроид

Этот туториал предназначен в первую очередь для новичков в разработке под андроид, но может быть будет полезен и более опытным разработчикам. Тут рассказано как создать простейшую 2D игру на анроиде без использования каких-либо игровых движков. Для этого я использовал Android Studio, но можно использовать любую другую соответствующее настроенную среду разработки.

Шаг 1. Придумываем идею игры
Для примера возьмём довольно простую идею:

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

Шаг 2. Создаём проект
В Android Studio в верхнем меню выбираем File → New → New Project.

Тут вводим название приложения, домен и путь. Нажимаем Next.

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

Тут обязательно выбираем Empty Activity. И жмём Next.

Тут оставляем всё как есть и жмём Finish. Итак проект создан. Переходим ко третьему шагу.

Шаг 3. Добавляем картинки

Скачиваем архив с картинками и распаковываем его.

Находим папку drawable и копируем туда картинки.

Позже они нам понадобятся.

Шаг 4. Создаём layout

Находим activity_main.xml, открываем вкладку Text и вставляем туда это:

На вкладке Design видно как наш layout будет выглядеть.

Сверху поле в котором будет сама игра, а снизу кнопки управления Left и Right. Про layout можно написать отдельную статью, и не одну. Я не буду на этом подробно останавливаться. Про это можно почитать тут.

Шаг 5. Редактируем MainActivity класс

В первую очередь в определение класса добавляем implements View.OnTouchListener. Определение класса теперь будет таким:

public class MainActivity extends AppCompatActivity implements View.OnTouchListener 

Добавим в класс нужные нам статические переменные (переменные класса):

public static boolean isLeftPressed = false; // нажата левая кнопка public static boolean isRightPressed = false; // нажата правая кнопка

В процедуру protected void onCreate(Bundle savedInstanceState) добавляем строки:

GameView gameView = new GameView(this); // создаём gameView LinearLayout gameLayout = (LinearLayout) findViewById(R.id.gameLayout); // находим gameLayout gameLayout.addView(gameView); // и добавляем в него gameView Button leftButton = (Button) findViewById(R.id.leftButton); // находим кнопки Button rightButton = (Button) findViewById(R.id.rightButton); leftButton.setOnTouchListener(this); // и добавляем этот класс как слушателя (при нажатии сработает onTouch) rightButton.setOnTouchListener(this);

Классы LinearLayout, Button и т.д. подсвечены красным потому что ещё не добавлены в Import.
Чтобы добавить в Import и убрать красную подсветку нужно для каждого нажать Alt+Enter.
GameView будет подсвечено красным потому-что этого класса ещё нет. Мы создадим его позже.

Теперь добавляем процедуру:

public boolean onTouch(View button, MotionEvent motion) < switch(button.getId()) < // определяем какая кнопка case R.id.leftButton: switch (motion.getAction()) < // определяем нажата или отпущена case MotionEvent.ACTION_DOWN: isLeftPressed = true; break; case MotionEvent.ACTION_UP: isLeftPressed = false; break; >break; case R.id.rightButton: switch (motion.getAction()) < // определяем нажата или отпущена case MotionEvent.ACTION_DOWN: isRightPressed = true; break; case MotionEvent.ACTION_UP: isRightPressed = false; break; >break; > return true; >

Если кто-то запутался ― вот так в результате должен выглядеть MainActivity класс:

package com.spaceavoider.spaceavoider; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.view.MotionEvent; import android.view.View; import android.widget.Button; import android.widget.LinearLayout; public class MainActivity extends AppCompatActivity implements View.OnTouchListener < public static boolean isLeftPressed = false; // нажата левая кнопка public static boolean isRightPressed = false; // нажата правая кнопка @Override protected void onCreate(Bundle savedInstanceState) < super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); GameView gameView = new GameView(this); // создаём gameView LinearLayout gameLayout = (LinearLayout) findViewById(R.id.gameLayout); // находим gameLayout gameLayout.addView(gameView); // и добавляем в него gameView Button leftButton = (Button) findViewById(R.id.leftButton); // находим кнопки Button rightButton = (Button) findViewById(R.id.rightButton); leftButton.setOnTouchListener(this); // и добавляем этот класс как слушателя (при нажатии сработает onTouch) rightButton.setOnTouchListener(this); >public boolean onTouch(View button, MotionEvent motion) < switch(button.getId()) < // определяем какая кнопка case R.id.leftButton: switch (motion.getAction()) < // определяем нажата или отпущена case MotionEvent.ACTION_DOWN: isLeftPressed = true; break; case MotionEvent.ACTION_UP: isLeftPressed = false; break; >break; case R.id.rightButton: switch (motion.getAction()) < // определяем нажата или отпущена case MotionEvent.ACTION_DOWN: isRightPressed = true; break; case MotionEvent.ACTION_UP: isRightPressed = false; break; >break; > return true; > >

Итак, класс MainActivity готов! В нём инициирован ещё не созданный класс GameView. И когда нажата левая кнопка — статическая переменная isLeftPressed = true, а когда правая — isRightPressed = true. Это в общем то и всё что он делает.

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

Шаг 6. Создаём класс GameView

Теперь наконец-то создадим тот самый недостающий класс GameView. Итак приступим. В определение класса добавим extends SurfaceView implements Runnable. Мобильные устройства имею разные разрешения экрана. Это может быть старенький маленький телефон с разрешением 480x800, или большой планшет 1800x2560. Для того чтобы игра выглядела на всех устройствах одинаково я поделил экран на 20 частей по горизонтали и 28 по вертикали. Полученную единицу измерения я назвал юнит. Можно выбрать и другие числа. Главное чтобы отношение между ними примерно сохранялось, иначе изображение будет вытянутым или сжатым.

public static int maxX = 20; // размер по горизонтали public static int maxY = 28; // размер по вертикали public static float unitW = 0; // пикселей в юните по горизонтали public static float unitH = 0; // пикселей в юните по вертикали

unitW и unitW мы вычислим позже. Также нам понадобятся и другие переменные:

private boolean firstTime = true; private boolean gameRunning = true; private Ship ship; private Thread gameThread = null; private Paint paint; private Canvas canvas; private SurfaceHolder surfaceHolder;

Конструктор будет таким:

public GameView(Context context) < super(context); //инициализируем обьекты для рисования surfaceHolder = getHolder(); paint = new Paint(); // инициализируем поток gameThread = new Thread(this); gameThread.start(); >

Метод run() будет содержать бесконечный цикл. В начале цикла выполняется метод update()
который будет вычислять новые координаты корабля. Потом метод draw() рисует корабль на экране. И в конце метод control() сделает паузу на 17 миллисекунд. Через 17 миллисекунд run() запустится снова. И так до пока переменная gameRunning == true. Вот эти методы:

@Override public void run() < while (gameRunning) < update(); draw(); control(); >> private void update() < if(!firstTime) < ship.update(); >> private void draw() < if (surfaceHolder.getSurface().isValid()) < //проверяем валидный ли surface if(firstTime)< // инициализация при первом запуске firstTime = false; unitW = surfaceHolder.getSurfaceFrame().width()/maxX; // вычисляем число пикселей в юните unitH = surfaceHolder.getSurfaceFrame().height()/maxY; ship = new Ship(getContext()); // добавляем корабль >canvas = surfaceHolder.lockCanvas(); // закрываем canvas canvas.drawColor(Color.BLACK); // заполняем фон чёрным ship.drow(paint, canvas); // рисуем корабль surfaceHolder.unlockCanvasAndPost(canvas); // открываем canvas > > private void control() < // пауза на 17 миллисекунд try < gameThread.sleep(17); >catch (InterruptedException e) < e.printStackTrace(); >>

Обратите внимание на инициализацию при первом запуске. Там мы вычисляем количество пикселей в юните и добавляем корабль. Корабль мы ещё не создали. Но прежде мы создадим его родительский класс.

Шаг 7. Создаём класс SpaceBody

Он будет родительским для класса Ship (космический корабль) и Asteroid (астероид). В нём будут содержаться все переменные и методы общие для этих двух классов. Добавляем переменные:

protected float x; // координаты protected float y; protected float size; // размер protected float speed; // скорость protected int bitmapId; // id картинки protected Bitmap bitmap; // картинка
void init(Context context) < // сжимаем картинку до нужных размеров Bitmap cBitmap = BitmapFactory.decodeResource(context.getResources(), bitmapId); bitmap = Bitmap.createScaledBitmap( cBitmap, (int)(size * GameView.unitW), (int)(size * GameView.unitH), false); cBitmap.recycle(); >void update() < // тут будут вычисляться новые координаты >void drow(Paint paint, Canvas canvas)< // рисуем картинку canvas.drawBitmap(bitmap, x*GameView.unitW, y*GameView.unitH, paint); >

Шаг 8. Создаём класс Ship

Теперь создадим класс Ship (космический корабль). Он наследует класс SpaceBody поэтому в определение класа добавим extends SpaceBody.

public Ship(Context context) < bitmapId = R.drawable.ship; // определяем начальные параметры size = 5; x=7; y=GameView.maxY - size - 1; speed = (float) 0.2; init(context); // инициализируем корабль >

и переопределим метод update()

@Override public void update() < // перемещаем корабль в зависимости от нажатой кнопки if(MainActivity.isLeftPressed && x >= 0) < x -= speed; >if(MainActivity.isRightPressed && x >

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

Шаг 9. Создаём класс Asteroid

Добавим класс Asteroid (астероид). Он тоже наследует класс SpaceBody поэтому в определение класса добавим extends SpaceBody.

Добавим нужные нам переменные:

private int radius = 2; // радиус private float minSpeed = (float) 0.1; // минимальная скорость private float maxSpeed = (float) 0.5; // максимальная скорость

Астероид должен появляться в случайной точке вверху экрана и лететь вниз с случайной скоростью. Для этого x и speed задаются при помощи генератора случайных чисел в его конструкторе.

public Asteroid(Context context)

Астероид должен двигаться с определённой скорость вертикально вниз. Поэтому в методе update() прибавляем к координате x скорость.

@Override public void update()

Так же нам нужен будет метод определяющий столкнулся ли астероид с кораблём.

public boolean isCollision(float shipX, float shipY, float shipSize) < return !(((x+size) < shipX)||(x >(shipX+shipSize))||((y+size) < shipY)||(y >(shipY+shipSize))); >

Рассмотрим его поподробнее. Для простоты считаем корабль и астероид квадратами. Тут я пошёл от противного. То есть определяю когда квадраты НЕ пересекаются.

((x+size) < shipX) — корабль слева от астероида.
(x > (shipX+shipSize)) — корабль справа от астероида.
((y+size) < shipY) — корабль сверху астероида.
(y > (shipY+shipSize)) — корабль снизу астероида.

Между этими четырьмя выражениями стоит || (или). То есть если хоть одно выражение правдиво (а это значит что квадраты НЕ пересекаются) — результирующие тоже правдиво.

Всё это выражение я инвертирую знаком!. В результате метод возвращает true когда квадраты пересекаются. Что нам и надо.

Про определение пересечения более сложных фигур можно почитать тут.

Шаг 10. Добавляем астероиды в GameView

В GameView добавляем переменные:

private ArrayList asteroids = new ArrayList<>(); // тут будут харанится астероиды private final int ASTEROID_INTERVAL = 50; // время через которое появляются астероиды (в итерациях) private int currentTime = 0;

также добавляем 2 метода:

private void checkCollision() < // перебираем все астероиды и проверяем не касается ли один из них корабля for (Asteroid asteroid : asteroids) < if(asteroid.isCollision(ship.x, ship.y, ship.size))< // игрок проиграл gameRunning = false; // останавливаем игру // TODO добавить анимацию взрыва >> > private void checkIfNewAsteroid()< // каждые 50 итераций добавляем новый астероид if(currentTime >= ASTEROID_INTERVAL)< Asteroid asteroid = new Asteroid(getContext()); asteroids.add(asteroid); currentTime = 0; >else < currentTime ++; >>

И в методе run() добавляем вызовы этих методов перед вызовоом control().

@Override public void run() < while (gameRunning) < update(); draw(); checkCollision(); checkIfNewAsteroid(); control(); >>

Далее в методе update() добавляем цикл который перебирает все астероиды и вызывает у них метод update().

private void update() < if(!firstTime) < ship.update(); for (Asteroid asteroid : asteroids) < asteroid.update(); >> >

Такой же цикл добавляем и в метод draw().

private void draw() < if (surfaceHolder.getSurface().isValid()) < //проверяем валидный ли surface if(firstTime)< // инициализация при первом запуске firstTime = false; unitW = surfaceHolder.getSurfaceFrame().width()/maxX; // вычисляем число пикселей в юните unitH = surfaceHolder.getSurfaceFrame().height()/maxY; ship = new Ship(getContext()); // добавляем корабль >canvas = surfaceHolder.lockCanvas(); // закрываем canvas canvas.drawColor(Color.BLACK); // заполняем фон чёрным ship.drow(paint, canvas); // рисуем корабль for(Asteroid asteroid: asteroids) < // рисуем астероиды asteroid.drow(paint, canvas); >surfaceHolder.unlockCanvasAndPost(canvas); // открываем canvas > >

Вот и всё! Простейшая 2D игра готова. Компилируем, запускаем и смотрим что получилось!
Если кто-то запутался или что-то не работает можно скачать исходник.

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

На этом всё. Пишите отзывы, вопросы, интересующие вас темы для продолжения.

  • Разработка игр
  • Разработка под Android

Как разработать игру для android

По данным Admob , 62% пользователей смартфонов устанавливают игру в течение первой недели после покупки устройства, однако лишь немногие мобильные игры могут окупить затраты на разработку. Создание приложений для Android имеет определенные особенности:

  • только 3% игроков тратят деньги в мобильных приложениях;
  • 80% продаж магазинов приложений составляют именно игры;
  • в 2010 году объем рынка мобильных игр оценивался в 33 миллиарда долларов;
  • в 2017 году выручка от мобильных игр превысила 50 миллиардов долларов, что составляет 43% всего мирового игрового рынка;
  • женщины в возрасте от 35 до 44 лет являются основными потребителями мобильных игр;
  • в 2015 году в США насчитывалось почти 165 миллионов игроков, использующих только мобильные устройства. Ожидается, что в 2021 году их количество увеличится до 213 миллионов

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

Популярные жанры

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

�� Как начать разрабатывать мобильные игры для Android?

MOBA

Multiplayer Battle Arenas представляет собой смесь стратегии в реальном времени и экшена. Игроку предоставляется контроль над одним персонажем, известным как «чемпион» или «герой», которого можно выбрать из большого списка. Нужно собрать команду и разрушить базу противников, защищая собственную.

Arena of Valor, Vainglory и Mobile Legends входят в тройку лучших MOBA для телефонов и планшетов. В 2017 году в Arena of Valor было 200 миллионов уникальных игроков, а ежедневное среднее их количество игроков достигло 80 миллионов, что сделало игру самой популярной в мире.

Battle Royale

Battle Royale сочетает выживание с разведкой, собирая большое количество игроков с минимальным снаряжением для поиска оружия и устранения противников. Цель игры – стать последним выжившим. Идея частично вдохновлена ​​японским фильмом ужасов « Королевская битва » 2000 года. Игрок соперничает с конкурентами, пытаясь оставаться в безопасной зоне, которая со временем сужается. Победителем становится последний выживший.

В наши дни крупные стримеры на Twitch транслируют одну или несколько игр Battle Royale, что является причиной массового притока новичков в возрасте преимущественно от 8 до 14 лет. Обычно приложения Battle Royale бесплатны и монетизируются за счет продажи внутриигровых предметов как и MOBA.

MMORPG

Многопользовательские ролевые онлайн-игры – это онлайн-RPG, в которой игроки повышают уровень персонажей и приобретают новые способности. При этом сотни аккаунтов одновременно взаимодействуют друг с другом в одном мире и в режиме реального времени.

Мобильные MMORPG – упрощенные версии ПК-аналогов, здесь игроки не хотят тратить часы и месяцы на изучение механики. Некоторые мобильные MMORPG чрезвычайно популярны: например, Arcane Legends и Dungeon Hunter 5 .

Головоломки

Более половины пользователей смартфонов и планшетов играют в головоломки. Candy Crush и Tetris популярны даже среди аудитории, которая не идентифицируют себя как геймеров. Логические игры сосредоточены на простых для понимания, но сложных в освоении механиках и требуют использования быстрого мышления.

Казуальные игры

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

�� Как начать разрабатывать мобильные игры для Android?

Выбор движка

Как только у вас появится идея игры, стоит определиться с инструментами разработки. Можно написать приложение с нуля на Java или Kotlin в Android Studio или использовать веб-интерфейс из стандартного стека HTML5, JavaScript и CSS. В качестве примера такого подхода приведем пошаговое создание легендарной игры Bejeweled на jQuery . Д ля более масштабных проектов стоит присмотреться к специальным движкам.

Unity

Unity – интуитивно понятный движок с широким набором функций для разработки кроссплатформенных мобильных игр. Он поддерживает импорт из 3dsMax, Maya, Softimage, CINEMA 4D, Blender.

Хотя Unity интегрируется со всеми основными 3D-приложениями, внутренние его возможности для редактирования имеют множество ограничений. За исключением некоторых примитивных форм, все должно быть создано с помощью сторонней программы. С новой 64-битной архитектурой и поддержкой WebGL, Unity 5 считается сильным решением. Это одна из наиболее часто используемых платформ, так что вы легко найдете в сети множество учебных пособий и руководств .

Unreal

Unreal Engine 4 – последняя на данный момент версия движка UDK, выпущенного Epic Games. В нем есть возможности разработки игр для мобильных устройств, ПК и консолей. UE4 обеспечивает потрясающие графические возможности, вроде расширенного динамического освещения или одновременной обработки до миллиона частиц в сцене.

В новом движке Unreal Engine 4 много изменений по сравнению с предыдущими версиями. В качестве языка сценариев используется C++, который полностью заменил популярный некогда UnrealScript, а Kismet заменен более удобной системой Blueprint. Из-за этих перемен даже опытным дизайнерам игр придется пройти обучение, чтобы освоить обновленную версию.

Unreal поставляется с инструментами для использования технологий виртуальной и дополненной реальности для разработки мобильных игр. В «Библиотеке программиста» вы найдете подробный туториал по созданию первого объекта и освоению Unreal Engine 4.

Solar2D

Solar2D ранее был известен как Corona SDK – это кроссплатформенный инструмент разработки, использующий язык сценариев Lua . Corona Market Place содержит множество плагинов для игр 2D. Движок славится четкой документацией и активным сообществом, а о дним из недостатков Solar2D является отсутствие инструментов 3D-моделирования.

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

�� Как начать разрабатывать мобильные игры для Android?

Основные этапы разработки

Чтобы создать мобильную игру под Android, нужно пройти определенные этапы. Предлагаем вашему вниманию пошаговый план для начинающих:

Идея и план

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

Распространенный прием – модифицировать существующую идею вместо того, чтобы придумывать новую с нуля. И побольше импровизировать.

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

Выбор языка и инструментов

Вы можете выбрать любой движок и подход к разработке, в любом случае придется засесть за изучение языка программирования. Отличным началом может стать C# , Kotlin или Java .

Графика

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

Для создания деталей стоит овладеть основами Photoshop и/или GIMP для 2D, а также 3dsMax и/или Blender для 3D-моделей.

Работа в команде

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

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

Тестирование, запуск и монетизация

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

Стоит подумать и о монетизации проекта. Существует несколько распространенных способов о ней позаботиться:

  • Покупки в приложении. Модель Freemium – самый распространенный метод монетизации мобильных игр.
  • Реклама. Во многих играх реклама сочетается с покупками в приложении. Эта стратегия может принести значительный доход.
  • Премиум-версии. Можно предложить игрокам бесплатную демо-версию и попросить заплатить за дальнейшее использование.

Развитие проекта

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

Стремительное развитие смартфонов за 10 лет изменило игровую индустрию кардинально. От простых карманных консолей Game Boy мобильные игры эволюционировали до многопользовательских платформ со сложной механикой и интересной визуальной составляющей. Это не должно вас пугать: даже простые проекты в стиле платформеров и казуальных игр могут найти свою аудиторию благодаря доступности и простому управлению.

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

Сам написал, сам поиграл: как я написал 2D-игру для Android полностью с нуля, весом менее 1мб?

image

Многие программисты так или иначе имеют тягу и интерес к разработке игр. Немалое количество спецов было замечено за написанием маленьких и миленьких игрушек, которые были разработаны за короткое время «just for fun». Большинству разработчиков за счастье взять готовый игровой движок по типу Unity/UE и попытаться создать что-то своё с их помощью, особенно упорные изучают и пытаются что-то сделать в экзотических движках типа Godot/Urho, а совсем прожжённые ребята любят писать игрушки… с нуля. Таковым любителем писать все сам оказался и я. И в один день мне просто захотелось написать что-нибудь прикольное, мобильное и обязательно — двадэшное! В этой статье вы узнаете про: написание производительного 2D-рендерера с нуля на базе OpenGL ES, обработку «сырого» ввода в мобильных играх, организацию архитектуры и игровой логики и адаптация игры под любые устройства. Интересно? Тогда жду вас в статье!

❯ Как это работает?

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

image

Один из прошлых проектов — 3D шутэмап под… коммуникаторы с Windows Mobile без видеоускорителей! Игра отлично работала и на HTC Gene, и на QTek S110!

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

image

Подобные инструменты включают в себя как довольно функциональные конструкторы игр, которые обычно не требуют серьёзных навыков программирования и позволяют собирать игру из логических блоков, так и полноценных игровых движков на манер Unity или Unreal Engine, которые позволяют разработчикам писать игры и продумывать их архитектуру самим. Можно сказать что именно «благодаря» доступности подобных инструментов мы можем видеть текущую ситуацию на рынке мобильных игр, где балом правят очень простые и маленькие донатные игрушки, называемые гиперкежуалом.

Но у подобных инструментов есть несколько минусов, которые банально не позволяют их использовать в реализации некоторых проектов:

  • Большой вес приложения: При сборке, Unity и UE создают достаточно объёмные пакеты из-за большого количества зависимостей. Таким образом, даже пустой проект может спокойно весить 50-100 мегабайт.
  • Неоптимальная производительность: И у Unity, и у UE очень комплексные и сложные рендереры «под капотом». Если сейчас купить дешевый смартфон за 3-4 тысячи рублей и попытаться на него накатить какой-нибудь 3 в ряд, то нас ждут либо вылеты, либо дикие тормоза.

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

❯ Определяемся с задачами

Перед тем, как садится и пилить игрушку, нужно сразу же определится с целями и поставить перед собой задачи — какой стек технологий мы будет использовать, как будем организовать игровую логику, на каких устройствах игра должна работать и.т.п. Я прикинул и решил реализовать что-то совсем несложное, но при этом достаточно динамичное и забавное… 2D-шутер с видом сверху!

image

Игра будет написана полностью на Java — родном языке для Android-приложений. Пустые пакеты без зависимостей весят всего около 20 килобайт — что только нам на руку! Ни AppCompat, ни какие либо ещё библиотеки мы использовать не будем — нам нужен минимальный размер из возможных!

Итак, что должно быть в нашей игре:

  • Основная суть: Вид сверху, человечком по центру экрана можно управлять и стрелять во вражин. Цель заключается в том, чтобы набрать как можно больше очков перед тем, как игрока загрызут. За каждого поверженного врага начисляются баксы, за которые можно купить новые пушки!
  • Оружие: Несколько видов вооружения, в том числе пистолеты, дробовики, автоматы и даже пулеметы! Всё оружие можно купить в внутриигровом магазине за валюту, которую игрок заработал во время игры
  • Враги: Два типа врагов — обычный зомби и «шустрик». Враги спавнятся в заранее предусмотренных точках и начинают идти (или бежать) в сторону игрока с целью побить его.
  • Уровни: Можно сказать, простые декорации — на момент написания статьи без какого либо интерактива.
  • Графика: Аппаратно-ускоренный рендерер полупрозрачных 2D-спрайтов с возможность аффинных трансформаций (поворот/масштаб/искривление и.т.п). На мобильных устройствах нужно поддерживать число DIP'ов (вызовов отрисовки) как можно ниже — для этого используется техника батчинга. Сам рендерер работает на базе OpenGLES 1.1 — т.е чистый FFP.
  • Ввод: Обработка тачскрина и геймпадов. Оба способа ввода очень легко реализовать на Android — для тачскрина нам достаточно повесить onTouchListener на окно нашей игры, а для обработки кнопок — ловить события onKeyListener и сопоставлять коды кнопок с кнопками нашего виртуального геймпада.
  • Звук: Воспроизведение как «маленьких» звуков, которые можно загрузить целиком в память (выстрелы, звуки шагов и… т.п), так и музыки/эмбиента, которые нужно стримить из физического носителя. Тут практически всю работу делает за нас сам Android, для звуков есть класс — SoundPool (который, тем не менее, не умеет сообщать о статусе проигрывания звука), для музыки — MediaPlayer. Есть возможность проигрывать PCM-сэмплы напрямую, чем я и воспользовался изначально, но с ним есть проблемы.
  • «Физика»: Я не зря взял этот пункт в кавычки. 🙂 По сути, вся физика у нас — это один метод для определения AABB (пересечения прямоугольник с прямоугольником). Всё, ни о какой настоящей физике и речи не идет. 🙂

image

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

❯ Рендерер

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

private void attachMainLoop() < GLView.setRenderer(new GLSurfaceView.Renderer() < @Override public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) < Engine.log("GL context successfully created"); Engine.log("Vendor: %s", GLES10.glGetString(GLES10.GL_VENDOR)); Engine.log("Renderer: %s", GLES10.glGetString(GLES10.GL_RENDERER)); Text = new TextRenderer(); setupRenderState(); Engine.Current.loadResources(); >@Override public void onSurfaceChanged(GL10 gl10, int w, int h) < DeviceWidth = w; DeviceHeight = h; GLES10.glMatrixMode(GLES10.GL_PROJECTION); GLES10.glLoadIdentity(); GLES10.glOrthof(0, w, h, 0, 0, 255); Camera.autoAdjustDistance(w, h); Engine.log("New render target resolution: %dx%d", w, h); >@Override public void onDrawFrame(GL10 gl10) < Engine.Current.drawFrame(); >>); GLView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); Engine.Current.MainActivity.setContentView(GLView); > 

По сути, в современном мире, 2D — это частный случай 3D, когда рисуются всё те же примитивы в виде треугольников, но вместо перспективной матрицы, используется ортографическая матрица определенных размеров. Во времена актуальности DirectDraw (середина-конец 90х) и Java-телефонов, графику обычно не делали адаптивной, из-за чего при смене разрешения, игровое поле могло растягиваться на всю площадь дисплея. Сейчас же, когда разброс разрешений стал колоссальным, чаще всего можно встретить два подхода к организацию проекции:

  • Установка ортографической матрицы в фиксированные размеры: Если координатная система уже была завязана на пиксели, или по какой-то причине хочется использовать именно её, то можно просто завязать игру на определенном разрешении (например, 480x320, или 480x800). Растеризатор формально не оперирует с пикселями — у него есть нормализованные координаты -1..1 (где -1 — начало экрана, 0 — середина, 1 — конец, это называется clip-space), а матрица проекции как раз и переводит координаты геометрии в camera-space координатах в clip-space — т.е в нашем случае, автоматически подгоняет размеры спрайтов из желаемого нами размера в физический. Обратите внимание, физические движки обычно рассчитаны на работу в метрических координатных системах. Попытки задавать ускорения в пикселях вызывают рывки и баги.
  • Перевод координатной системы с пиксельной на метрическую/абстрактную:
    Сейчас этот способ используется чаще всего, поскольку именно его используют самые популярные движки и фреймворки. Если говорить совсем просто — то мы задаем координаты объектов и их размеры не относительно пикселей, а относительно размеров этих объектов в метрах, или ещё какой-либо абстрактной системы координат. Этот подход близок к обычной 3D-графике и имеет свои плюшки: например, можно выпустить HD-пак для вашей игры и заменить все спрайты на варианты с более высоким разрешением, не переделывая половину игры.
public void drawSprite(Sprite spr, float x, float y, float width, float height, float z, float rotation, Color col) < if(spr != null) < if(col == null) col = Color.White; if(width == 0) width = spr.Width; if(height == 0) height = spr.Height; // Convert position from world space to screen space x = x - Camera.X; y = y - Camera.Y; if(x >ViewWidth || y > ViewHeight || x + width < 0 || y + height < 0) < Statistics.OccludedDraws++; return; >GLES10.glEnable(GLES10.GL_TEXTURE_2D); GLES10.glBindTexture(GLES10.GL_TEXTURE_2D, spr.TextureId); GLES10.glMatrixMode(GLES10.GL_MODELVIEW); GLES10.glLoadIdentity(); GLES10.glTranslatef(x + (width / 2), y + (height / 2), 0); GLES10.glRotatef(rotation, 0, 0, 1); GLES10.glTranslatef(-(width / 2), -(height / 2), 0); GLES10.glScalef(width, height, 1.0f); vertex(0, 0, 0, 0, col); vertex(1, 0, 1, 0, col); vertex(1, 1, 1, 1, col); vertex(0, 0, 0, 0, col); vertex(0, 1, 0, 1, col); vertex(1, 1, 1, 1, col); vPosBuf.rewind(); vColBuf.rewind(); vUVBuf.rewind(); GLES10.glVertexPointer(2, GLES10.GL_FLOAT, 0, vPosBuf); GLES10.glColorPointer(4, GLES10.GL_FLOAT, 0, vColBuf); GLES10.glTexCoordPointer(2, GLES10.GL_FLOAT, 0, vUVBuf); GLES10.glDrawArrays(GLES10.GL_TRIANGLES, 0, 6); Statistics.DrawCalls++; > > private void vertex(float x, float y, float u, float v, Color col)

Всё более чем понятно — преобразуем координаты спрайта из world-space в camera-space, отсекаем спрайт, если он находится за пределами экрана, задаем стейты для GAPI (на данный момент, их всего два), заполняем вершинный буфер геометрией и рисуем на экран. Никакого смысла использовать VBO здесь нет, а на nio-буфферы можно получить прямой указатель без лишних копирований, так что никаких проблем с производительностью не будет. Обратите внимание — вершинный буфер выделяется заранее — аллокации каждый дравколл нам не нужны и вредны.

 // Vertex format: // vec2 pos; -- 8 bytes // vec4 color; -- 16 bytes // vec2 uv; -- 8 bytes // 32 bytes total int numVerts = 6; vPosBuf = ByteBuffer.allocateDirect((4 * 8) * numVerts); vColBuf = ByteBuffer.allocateDirect((4 * 16) * numVerts); vUVBuf = ByteBuffer.allocateDirect((4 * 8) * numVerts); vPosBuf.order(ByteOrder.LITTLE_ENDIAN); vColBuf.order(ByteOrder.LITTLE_ENDIAN); vUVBuf.order(ByteOrder.LITTLE_ENDIAN);

Обратите внимание на вызовы ByteBuffer.order — это важно, по умолчанию, Java создаёт все буферы в BIG_ENDIAN, в то время как большинство Android-устройств — LITTLE_ENDIAN, из-за этого можно запросто накосячить и долго думать «а почему у меня буферы заполнены правильно, но геометрии на экране нет!?».

image

В процессе разработки игры, при отрисовке относительно небольшой карты с большим количеством тайлов, количество вызовов отрисовки возросло аж до 600, из-за чего FPS в игре очень сильно просел. Связано это с тем, что на старых мобильных GPU каждый вызов отрисовки означал пересылку состояния сцены видеочипу, из-за чего мы получали лаги. Фиксится это довольно просто: реализацией батчинга — специальной техники, которая «сшивает» большое количество спрайтов с одной текстурой в один и позволяет отрисовать хоть 1000, хоть 100000 спрайтов в один проход! Есть два вида батчинга, статический — когда объекты «сшиваются» при загрузке карты/в процессе компиляции игры (привет Unity) и динамический — когда объекты сшиваются прямо на лету (тоже привет Unity). На более современных мобильных GPU с поддержкой GLES 3.0 есть также инстансинг — схожая технология, но реализуемая прямо на GPU. Суть её в том, что мы передаём в шейдер параметры объектов, которые мы хотим отрисовать (матрицу, настройки материала и.т.п) и просим видеочип отрисовать одну и ту же геометрию, допустим, 15 раз. Каждая итерация отрисовки геометрии будет увеличивать счетчик gl_InstanceID на один, благодаря чему мы сможем расставить все модельки на свои места! Но тут уж справедливости ради стоит сказать, что в D3D10+ можно вообще стейты передавать на видеокарту «пачками», что здорово снижает оверхед одного вызова отрисовки.

image

Для загрузки спрайтов используется встроенный в Android декодер изображений. Он умеет работать в нескольких режимах (ARGB/RGB565 и.т.п), декодировать кучу форматов — в том числе и jpeg, что положительно скажется на финальном размере игры.

public void upload(ByteBuffer data, int width, int height, int format) < if(data != null) < int len = data.capacity(); GLES10.glEnable(GLES10.GL_TEXTURE_2D); GLES10.glBindTexture(GLES10.GL_TEXTURE_2D, TextureId); GLES10.glTexImage2D(GLES10.GL_TEXTURE_2D, 0, GLES10.GL_RGBA, width, height, 0, GLES10.GL_RGBA, GLES10.GL_UNSIGNED_BYTE, data); GLES11.glTexParameteri(GLES10.GL_TEXTURE_2D, GLES10.GL_TEXTURE_MIN_FILTER, GLES10.GL_NEAREST); GLES11.glTexParameteri(GLES10.GL_TEXTURE_2D, GLES10.GL_TEXTURE_MAG_FILTER, GLES10.GL_NEAREST); Width = width; Height = height; >> public static Sprite load(String fileName) < InputStream is = null; try < is = Engine.Current.MainActivity.getAssets().open("sprites/" + fileName); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inPreferredConfig = Bitmap.Config.ARGB_8888; Bitmap bmp = BitmapFactory.decodeStream(is, null, opts); ByteBuffer buf = ByteBuffer.allocateDirect(bmp.getRowBytes() * bmp.getHeight()); bmp.copyPixelsToBuffer(buf); buf.rewind(); Sprite ret = new Sprite(); ret.upload(buf, bmp.getWidth(), bmp.getHeight(), FORMAT_RGBA); return ret; >catch (IOException e) < Engine.log("Failed to load sprite %s", fileName); throw new RuntimeException(e); >>

На этом реализация рендерера закончена. Да, все вот так просто 🙂
Переходим к двум остальным модулям — звук и ввод.

❯ Звук и ввод

Как я уже говорил, звук я решитл реализовать на базе уже существующей звуковой подсистемы Android. Ничего сложного в её реализацир нет, можно сказать, нам остаётся лишь написать обёртку, необходимую для работы. Изначально я написал собственный загрузчик wav-файлов и хотел использовать AudioTrack — класс для воспрозизведения PCM-звука напрямую, но мне не понравилось, что в нём нет разделения на источники звука и буферы, из-за чего каждый источник вынужден заниматься копированием PCM-потока в новый и новый буфер…

image

Полная реализация звукового потока выглядит так. И да, с SoundPool нет возможности получить позицию проигрывания звука или узнать, когда проигрывание закончилось. Увы.

public static class Instance < private AudioStream parent; private int id; Instance(AudioStream parent) < this.parent = parent; >public void play() < Audio.MasterAudioLevel, Audio.MasterAudioLevel, 0, 0, 1.0f); >public void stop() < sharedPool.stop(id); >> private static SoundPool sharedPool; private int streamId; static < Engine.log("Allocating SoundPool"); sharedPool = new SoundPool(255, AudioManager.STREAM_MUSIC, 0); >public AudioStream(int streamId) < this.streamId = streamId; >@Override protected void finalize() throws Throwable < sharedPool.unload(streamId); super.finalize(); >public static AudioStream load(String fileName) < AssetManager assets = Engine.Current.MainActivity.getAssets(); try < AssetFileDescriptor afd = assets.openFd("sounds/" + fileName); int streamId = sharedPool.load(afd, 0); return new AudioStream(streamId); >catch (IOException e) < Engine.log("Failed to load audio stream %s", fileName); return null; >> 

Не забываем и про музыку:

private MediaPlayer mediaPlayer; private boolean ready; public MusicStream(MediaPlayer player) < mediaPlayer = player; >public void forceRelease() < if(mediaPlayer.isPlaying()) mediaPlayer.stop(); mediaPlayer.release(); >public void play() < if(!mediaPlayer.isPlaying()) mediaPlayer.start(); >public void pause() < if(mediaPlayer.isPlaying()) mediaPlayer.pause(); >public void stop() < if(!mediaPlayer.isPlaying()) mediaPlayer.stop(); >public boolean isPlaying() < return mediaPlayer.isPlaying(); >public void setLoop(boolean isLooping) < mediaPlayer.setLooping(isLooping); >public static MusicStream load(String fileName) < AssetManager assets = Engine.Current.MainActivity.getAssets(); try < AssetFileDescriptor afd = assets.openFd("music/" + fileName); MediaPlayer player = new MediaPlayer(); player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); player.setVolume(0.3f, 0.3f); // TODO: Move volume settings to Audio player.prepare(); return new MusicStream(player); >catch (IOException e) < Engine.log("Failed to load audio stream %s", fileName); return null; >> 

Да будет звук! Ну и про ввод не забываем:

public static final int TOUCH_IDLE = 0; public static final int TOUCH_PRESSED = 1; public static final int TOUCH_RELEASED = 2; public interface TextCallback < void onEnteredText(String str); >public static class TouchState < public boolean State; public int Id; public float X, Y; >public static int GAMEPAD_A = 0; public static int GAMEPAD_B = 1; public static int GAMEPAD_Y = 2; public static int GAMEPAD_X = 3; public static int GAMEPAD_LT = 4; public static int GAMEPAD_RT = 5; public static int GAMEPAD_DPAD_LEFT = 6; public static int GAMEPAD_DPAD_RIGHT = 7; public static int GAMEPAD_DPAD_UP = 8; public static int GAMEPAD_DPAD_DOWN = 9; public static int GAMEPAD_BUTTON_COUNT = 10; public static class GamepadState < public float AnalogX, AnalogY; public boolean[] Buttons; GamepadState() < Buttons = new boolean[GAMEPAD_BUTTON_COUNT]; >> class TouchListener implements View.OnTouchListener < @Override public boolean onTouch(View view, MotionEvent motionEvent) < for(int i = 0; i < motionEvent.getPointerCount(); i++) < Touches[i].Id = motionEvent.getPointerId(i); // Convert from device-space to view-space. float xVal = motionEvent.getX() / Engine.Current.Graphics.DeviceWidth; float yVal = motionEvent.getY() / Engine.Current.Graphics.DeviceHeight; Touches[i].X = xVal * Engine.Current.Graphics.ViewWidth; Touches[i].Y = yVal * Engine.Current.Graphics.ViewHeight; if(motionEvent.getAction() == MotionEvent.ACTION_DOWN) Touches[i].State = true; if(motionEvent.getAction() == MotionEvent.ACTION_UP) Touches[i].State = false; >return true; > > public TouchState[] Touches; public GamepadState Gamepad; // Format - first int is KEYCODE mapped on Android, second is gamepad button private final int[] gamePadMapping = < KeyEvent.KEYCODE_DPAD_CENTER, GAMEPAD_A, KeyEvent.KEYCODE_BACK, GAMEPAD_B, KeyEvent.KEYCODE_BUTTON_Y, GAMEPAD_Y, KeyEvent.KEYCODE_BUTTON_X, GAMEPAD_X, KeyEvent.KEYCODE_DPAD_UP, GAMEPAD_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, GAMEPAD_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT, GAMEPAD_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, GAMEPAD_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, GAMEPAD_DPAD_DOWN >; public Input() < Touches = new TouchState[5]; for(int i = 0; i < Touches.length; i++) Touches[i] = new TouchState(); Gamepad = new GamepadState(); Engine.log("Initializing input subsystem. "); Engine.Current.Graphics.GLView.setOnTouchListener(new TouchListener()); >public int isTouchingZone(float x, float y, float w, float h) < boolean touching = false; for(int i = 0; i < Touches.length; i++) < touching = Touches[i].X >x && Touches[i].Y > y && Touches[i].X < x + w && Touches[i].Y < y + h; if(touching && Touches[i].State) return i; >return -1; > public boolean isAnyFingerInZone(float x, float y, float w, float h) < boolean touching = false; for(int i = 0; i < Touches.length; i++) < touching = Touches[i].X >x && Touches[i].Y > y && Touches[i].X < x + w && Touches[i].Y < y + h; if(touching && Touches[i].State) return true; >return false; > public void requestTextInput(String title, String target, TextCallback callback) < AlertDialog.Builder dlgBuilder = new AlertDialog.Builder(Engine.Current.MainActivity); TextView text = new TextView(Engine.Current.MainActivity); EditText editor = new EditText(Engine.Current.MainActivity); text.setText(target + ":"); editor.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); dlgBuilder.setTitle(title); LinearLayout layout = new LinearLayout(Engine.Current.MainActivity); layout.addView(text); layout.addView(editor); layout.setOrientation(LinearLayout.VERTICAL); layout.setPadding(5, 5, 5, 5); dlgBuilder.setView(layout); dlgBuilder.setPositiveButton("OK", new DialogInterface.OnClickListener() < @Override public void onClick(DialogInterface dialogInterface, int i) < callback.onEnteredText(editor.getText().toString()); >>); Engine.Current.MainActivity.runOnUiThread(new Runnable() < @Override public void run() < dlgBuilder.show(); >>); > 

Сама реализация джойстика крайне простая — запоминаем координаты, куда пользователь поставил палец и затем считаем дистанцию положения пальца относительно центральной точки, параллельно нормализовывая их относительно максимальной дистанции:

public class Joystick < private Sprite joySprite; public float VelocityX; public float VelocityY; public float OriginX, OriginY; private float fingerX, fingerY; private int joyFinger; public Joystick() < joySprite = Sprite.load("ui_button.png"); OriginX = -999; OriginY = -999; >private float clamp(float a, float min, float max) < return a < min ? min : (a >max ? max : a); > public void update() < int finger = 0; if((finger = Engine.Current.Input.isTouchingZone(0, 0, Engine.Current.Graphics.ViewWidth, Engine.Current.Graphics.ViewHeight)) != -1) < if(OriginX == -999) < OriginX = Engine.Current.Input.Touches[finger].X; OriginY = Engine.Current.Input.Touches[finger].Y; >float xdiff = (Engine.Current.Input.Touches[finger].X - OriginX) / Engine.Current.Graphics.ViewWidth; float ydiff = (Engine.Current.Input.Touches[finger].Y - OriginY) / Engine.Current.Graphics.ViewHeight; VelocityX = clamp(xdiff / 0.2f, -1, 1); VelocityY = clamp(ydiff / 0.2f, -1, 1); > else < OriginX = -999; OriginY = -999; >> public void draw() < VelocityX = 0; VelocityY = 0; >>

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

Основа для игры есть, теперь переходим к её реализации!

❯ Пишем игру

image

Писать игру я начал с создания первого уровня и реализации загрузчика уровней. В качестве редактора, я выбрал популярный и широко-известный TilEd — удобный редактор с возможностью экспорта карт в несколько разных форматов. Я лично выбрал Json, поскольку в Android уже есть удобный пакет для работы с этим форматом данных.

private void parseJson(String json) < try < JSONObject obj = new JSONObject(json); width = obj.getInt("width"); height = obj.getInt("height"); JSONArray jtileSet = obj.getJSONArray("tilesets").getJSONObject(0).getJSONArray("tiles"); for(int i = 0; i < jtileSet.length(); i++) < JSONObject tile = jtileSet.getJSONObject(i); String name = tile.getString("image"); name = name.substring(name.lastIndexOf("/") + 1); tileSet[tile.getInt("id")] = Sprite.load(name); >JSONArray layers = obj.getJSONArray("layers"); this.tiles = new byte[width * height]; Engine.log("Level size %d %d", width, height); for(int i = 0; i < layers.length(); i++) < JSONObject layer = layers.getJSONObject(i); boolean isTileData = layer.has("data"); if(isTileData) < JSONArray tiles = layer.getJSONArray("data"); Engine.log("Loading tile data"); for(int j = 0; j < tiles.length(); j++) this.tiles[j] = (byte)(tiles.getInt(j) - 1); >else < JSONArray objects = layer.getJSONArray("objects"); for(int j = 0; j < objects.length(); j++) < JSONObject jobj = objects.getJSONObject(j); Prop prop = new Prop(); prop.Sprite = tileSet[jobj.getInt("gid") - 1]; prop.Name = jobj.getString("name"); prop.X = (float)jobj.getDouble("x"); prop.Y = (float)jobj.getDouble("y"); prop.Visible = true; String type = jobj.getString("type"); if(type.equals("invisible")) prop.Visible = false; props.add(prop); >> > > catch (JSONException e) < e.printStackTrace(); // Level loading is unrecoverable error throw new RuntimeException(e); >>
private void buildBatch() < batches = new HashMap(); for(int i = 0; i < width; i++) < for(int j = 0; j < height; j++) < Sprite tile = tileSet[tiles[j * width + i]]; if(!batches.containsKey(tile)) batches.put(tile, new Graphics2D.StaticBatch(tile, width * height)); batches.get(tile).addInstance(i * 32, j * 32, Graphics2D.Color.White); >> for(Sprite spr : batches.keySet()) < batches.get(spr).prepare(); >Engine.log("Generated %d batches", batches.size()); > 

Карта делится на 3 базовые понятия: тайлы — фон, с изображением травы/асфальта/земли и.т.п, пропы — статичные объекты по типу деревьев и кустов и сущности — объекты, участвующие в игровом процессе, т.е игрок, зомби и летящие пули. Система сущностей реализована в виде абстрактного базового класса, который реализовывает логику апдейтов, просчитывает Forward-вектор и выполняет другие необходимые задачи:

public abstract class Entity < public float X, Y; public float ForwardX, ForwardY; // Forward vector public float RightX, RightY; public float Rotation; public boolean IsVisible; public int DrawingOrder; public float distanceTo(float x, float y) < x = X - x; y = Y - y; return (float)Math.sqrt((x * x) + (y * y)); >public boolean AABBTest(Entity ent, float myWidth, float myHeight, float width, float height) < return X < ent.X + width && Y < ent.Y + height && ent.X < X + myWidth && ent.Y < Y + myHeight; >public void recalculateForward() < ForwardX = (float)Math.sin(Math.toRadians(Rotation)); ForwardY = -(float)Math.cos(Math.toRadians(Rotation)); >public void update() < recalculateForward(); >public void draw() < >>

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

@Override public void update() < super.update(); joyInput.update(); float inpX = joyInput.VelocityX; float inpY = joyInput.VelocityY; if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_LEFT]) < inpX = -1; Rotation = 270; >if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_RIGHT]) < inpX = 1; Rotation = 90; >if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_DOWN]) < inpY = 1; Rotation = 180; >if(Engine.Current.Input.Gamepad.Buttons[Input.GAMEPAD_DPAD_UP]) < inpY = -1; Rotation = 0; >X += inpX * (WALK_SPEED * Engine.Current.DeltaTime); Y += inpY * (WALK_SPEED * Engine.Current.DeltaTime); Engine.Current.Graphics.Camera.X = X - (Engine.Current.Graphics.ViewWidth / 2); Engine.Current.Graphics.Camera.Y = Y - (Engine.Current.Graphics.ViewHeight / 2); int finger = 0; if((finger = Engine.Current.Input.isTouchingZone(0, 0, Engine.Current.Graphics.ViewWidth, Engine.Current.Graphics.ViewHeight)) != -1) < Input.TouchState state = Engine.Current.Input.Touches[finger]; aimX = state.X; aimY = state.Y; // Convert player position from world-space, to screen-space float ptfX = (X - Engine.Current.Graphics.Camera.X) - state.X; float ptfY = (Y - Engine.Current.Graphics.Camera.Y) - state.Y; Rotation = (float)Math.toDegrees(Math.atan2(-ptfX, ptfY)); recalculateForward(); if(nextAttack < 0) < GunItem currGun = Guns.get(EquippedGun); currGun.Gun.FireEffect.createInstance().play(); nextAttack = currGun.Gun.Speed; Bullet bullet = new Bullet(); bullet.Speed = 15; bullet.LifeTime = 3.0f; bullet.Rotation = Rotation; bullet.Damage = currGun.Gun.Damage; float bullX = sprites[currGun.Gun.Sprite].Width / 2; float bullY = sprites[currGun.Gun.Sprite].Height / 2; float fwXFactor = ForwardX * 19; float fwYFactor = ForwardY * 19; bullet.X = X + bullX - (Bullet.Drawable.Width / 2) + fwXFactor; bullet.Y = Y + bullY - (Bullet.Drawable.Height / 2) + fwYFactor; Game.current.World.spawn(bullet); >> nextAttack -= Engine.Current.DeltaTime; >

image

Список доступных стволов хранится в статическом массиве GunDescription:

public static Gun[] GunDescription = < new Gun("Glock-18", Player.SPRITE_HANDGUN, 20.0f, 0.4f, 20, 90, "glock18.wav", "pistol.png", 1500), new Gun("UZI", Player.SPRITE_HANDGUN, 20.0f, 0.15f, 20, 90, "uzi.wav", "pistol.png", 1500), new Gun("Deagle", Player.SPRITE_HANDGUN, 100.0f, 0.7f, 20, 90, "deagle.wav", "pistol.png", 1500), new Gun("TOZ-34", Player.SPRITE_HANDGUN, 100.0f, 1.1f, 20, 90, "shotgun.wav", "pistol.png", 1500), new Gun("XM1014", Player.SPRITE_HANDGUN, 90.0f, 0.6f, 20, 90, "shotgun.wav", "pistol.png", 1500), new Gun("AK47", Player.SPRITE_HANDGUN, 40.0f, 1.1f, 20, 90, "ak47.wav", "pistol.png", 1500), new Gun("M4-A1", Player.SPRITE_HANDGUN, 90.0f, 0.6f, 20, 90, "m4.wav", "pistol.png", 1500), new Gun("MiniFGun", Player.SPRITE_HANDGUN, 30.0f, 0.15f, 20, 90, "minigun.wav", "pistol.png", 1500) >; 

Ну и не забываем про реализацию зомби. Она тоже очень простая: есть базовый класс Zombie, от которого наследуются все монстры и который реализует несколько необходимых методов — повернуться в сторону игрока, идти вперед и конечно же атака!

image

@Override public void update() < super.update(); Player player = Game.current.World.Player; rotateTowardsEntity(player); if(distanceTo(player.X, player.Y) >35) moveForward(WALK_SPEED * Engine.Current.DeltaTime); > 

❯ Что у нас есть на данный момент?

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

Как мы видим, игра (а пока что — proof of concept) работает довольно неплохо на всех устройствах, которые были выбраны для тестирования. Однако это ещё не всё — предстоит добавить конечную цель игры (набор очков), магазин стволов и разные типы мобов. Благо, это всё реализовать уже совсем несложно. 🙂

❯ Заключение

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

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

Возможно, захочется почитать и это:

  • ➤ Не дадим Windows Phone умереть! Как я написал свои клиенты VK, YouTube для Nokia Lumia? Сам себе экосистема
  • ➤ Сам себе игровая консоль: превращаем планшет с нерабочим тачскрином в игровой девайс из 8 кнопок и микроконтроллера
  • ➤ Чудовищное уравнение 45-ой степени, которое Франсуа Виет решил в 16 веке
  • ➤ Запуск приложений на .NET в качестве службы на Linux-системе с systemd
  • ➤ Программирование для Palm OS: что же там самое сложное?

  • Блог компании Timeweb Cloud
  • Программирование
  • Работа с векторной графикой
  • Разработка игр
  • Разработка под Android

Как я запустил свою первую мобильную игру и провалился

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

Коротко об игре

Шахматная доска, шахматные фигуры, но нет — не шахматы. В распоряжении игрока только одна фигура — конь. На верхней линии доски постепенно появляются вражеские фигуры, в основном пешки. Раз в секунду несколько вражеских фигур одновременно ходят. Количество ходов игрока при этом не ограничено. Фактически вас ограничивает лишь фреймрейт. У меня максимально получалось совершить 4 осмысленных хода между ходами соперника.

Цель игры: набрать как можно больше очков. Ценность срубленных фигур как в шахматах: срубленная пешка — 1 очко, слон и конь — 3 очка.

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

Поставил среднесрочную цель

На долгое время оставаться без дохода психологически тяжело. Срок в 2 месяца звучал как что-то не страшное, что я определенно могу себе позволить. Цель сформулировал так:

Если за 2 месяца на своей игре заработаю хотя бы 1 доллар, то «продлеваю» еще на месяц.

Дальнейшие цели я видел как увеличение суммы в 10 раз: за третий месяц заработать $10, за четвертый — $100 и т.д. В целом, от $1000 увеличение менее чем на порядок меня тоже бы устроило.

Выбор игрового движка

В плане движка практически сразу выбрал Unity.

Ранее я уже пробовал бесплатные open source решения для геймдева и для чего-то серьезного использовал бы их с осторожностью. Доводилось, например, работать с Godot (GDScript) и Corona SDK (Lua). Коммьюнити не такие большие, сырой инструментарий и непопулярные языки программирования.

Про Unreal Engine слышал, но подробно не смотрел. Со стороны кажется, что перебор для мини-игры, но могу ошибаться.

Unity же в моем случае — это лишь условно-платное решение, т.к. для персональных разработчиков с доходом до $100k в год основной сервис предоставляется бесплатно. На старте о таком доходе можно только мечтать.

Ну и C# достаточно приятный язык, особенно после Lua. С опытом в Java дался без проблем.

Разбил работу на отрезки с маленькими целями

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

Как инструмент для планирования использовал Miro — крутая визуализация, гибкий и мощный инструмент. Рекомендую.

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

В колонке «Текущее состояние» два скриншота того, что у меня было на старте: экран с доской и кнопкой Play. Плюс сырая механика.

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

Так выглядел мой первый спринт:

Строками выделены аспекты продукта, которые мне казались важными составляющими игры.

Критерии для запуска взял из небольшой статьи «13 главных ошибок, мешающих разрабатывать игры». Основной посыл критериев:

Запускайте игру и получайте фидбек как можно раньше. Не только игру, любой продукт.

Первый релиз в Google Play (дни 0-10)

Чтобы публиковать мобильные приложения и игры для Android, нужно завести аккаунт разработчика в Google Play. Единоразовый взнос (на осень 2022 года) — $25.

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

Перед каждой публикацией Google Play проверяет ваше приложение. Иногда вручную, но чаще автоматически. Автоматическая проверка в среднем занимает около четырех часов. Но первую версию, кажется, всегда проверяют вручную, что сильно дольше. Если вы приурочиваете запуск к определенной дате, позаботьтесь о заблаговременном прохождении проверки. Можно заранее пройти проверку, а опубликовать на пользователей уже в ручном режиме в желаемую дату.

Часть отчета о тестировании в Google Play

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

Хотя, на некоторых устройствах не совсем так:

Google Play предоставляет такие скриншоты при проверке каждой новой версии

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

С Android SDK я понимал как это обработать, а вот как с Unity — еще не знал. Пошел читать документацию и собирать информацию в интернете. UI есть в каждой игре, вот краткий список ресурсов, которые точно пригодятся:

  • Anchors — привязка UI элементов относительно краев экрана, официальная документация.
  • Content Size Fitter — растягивание элементов относительно содержимого, официальная документация.
  • Safe Area Helper — размещение элементов с учётом особенностей смартфонов с вырезами под камеры, библиотека.
  • Отдельная камера для UI, туториал на youtube.

Визуальный итог первого спринта:

Иконкой не горжусь. Рисовал ручкой на айпаде в платной программе ProCreate. Именно в плане геймдева не могу рекомендовать, но в целом как программа для рисования пойдет, племянникам нравится.

Итоги спринта:
  • Рабочий прототип, покрытый базовой аналитикой.
  • Первую версию Google Play проверял более 4-х суток.

Спустя неделю после релиза игру ожидаемо скачали 0 человек.

Отсутствие установок не было сюрпризом. Игра нигде не рекламировалась, не имела кликбейтового названия и в целом была неким экспериментом в плане игровой механики, так что и по жанру найти невозможно. Первое время мне, даже зная название своей игры, было сложно отыскать ее в Google Play. Название содержало приставку «Chess» и игру перекрывал топ хоть сколько-то популярных шахматных приложений.

Держите в уме, что получить первых пользователей без минимального вложения в рекламу вряд ли получится. Кажется, оптимальный вариант для получения первой обратной связи — пойти по знакомым и попросить их поиграть. Так я и сделал. Среди моих первых пользователей были жена и трое друзей.

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

  • Различного рода достижения и бонусы.
  • Онбординг для новичков, чтобы объяснить правила игры.
  • Мишени на пешках, чтобы было очевидно, что их можно срубить.

Монетизация free-to-play игр, встроил рекламу (дни 11-18)

Говорят, монетизировать продукт нужно начинать как можно раньше. В идеале даже MVP должен быть платным, чтобы вы понимали, что люди за ваш продукт готовы платить.

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

Дни 10-14 заняла проверка гуглом первой версии и новых данных для планирования у меня не было. Решил идти по изначальному плану. Так появился второй спринт, целью которого стало внедрить рекламу:

Встроить рекламу с помощью инструментов Unity оказалось достаточно просто, использовал Unity Mediation. Со всем управился за несколько часов, и у меня еще оставалось время на то, чтобы улучшить саму игру.

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

По сути на этой схеме смешаны: аспекты игры, бэклог и в каком-то роде стратегия развития. Аспекты в статье выше описаны не совсем так, как у меня. Схему я позже модернизировал, этот блок больше для истории.

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

Визуальный итог второго спринта:

Получилась какая-то около-японская тематика: сакура, фудзи, древесные кнопки.

Из интересного: кнопки моделировал в 3D, с прицелом что когда-нибудь добавлю им анимации взаимодействия. Пользовался мощной open source программой Blender. Определенно рекомендую. Потрясает, что продукты такого шикарного качества могут быть опенсорсными.

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

  1. Выпиливаем доску
  2. Гравируем текст
  3. Процедурно генерируем древесный материал

Попутно забавы ради реализовал еще и 3D-фигурку коня. Не пригодилась, но сам процесс мне понравился. Как освоение новой профессии.

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

Итоги спринта:
  • Внедрил монетизацию в виде рекламы.
  • Освоил базу 3D-моделирования.
  • Скачиваний: 3 — скачали друзья. Даже появилась одна оценка в 5 звезд — еще один плюс друзей как первых пользователей.

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

Как я решил отдохнуть от задач: тесты и рефакторинг (дни 19-22)

Не забывайте отдыхать.

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

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

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

Итоги «выходных»:

По прошествии двух месяцев в проекте 17 unit-тестов и 2 UI

  • Появились автотесты.
  • Оптимизировал часть алгоритмов.

Где взять установки?

У игры за предыдущий спринт появился опрятный UI, но по-прежнему не было реальных пользователей, поэтому следующей целью стало их найти.

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

Подготовка креативов для рекламной кампании

Очевидной площадкой для рекламы была Google Ads — самая большая и самая близкая к теме Android. Жена вызвалась помочь с настройкой рекламной кампании. Вот так она описала мне требования для креативов на этой площадке:

Итоговые материалы для рекламы:

  • Видео геймплея, которое также использую в Google Play на странице приложения.
  • Два изображения, на основе которых нарезал баннеры по требуемым форматам:

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

Для записи видео использовал встроенный в Unity видео-рекордер. Для создания баннеров подойдет практически любой фоторедактор. Обычно я вбиваю в гугл «фотошоп онлайн» и пользуюсь первым попавшимся.

Неудачная попытка запустить рекламу в Google Ads

Изначально мне не давали зарегистрироваться в Google Ads, т.к. мой платежный аккаунт Google был привязан к России. Перепривязал на свое текущее местоположение — Грузию, официальная инструкция. При этом нужно удалить российские платежные данные, иначе зарегистрироваться так и не даст. Менять страну можно не чаще раза в год.

Через пару дней новая локация проросла, дали зарегистрироваться. Но при попытке запустить рекламу получил бан «за подозрительные транзакции». Подумал, что это связано с тем, что для оплаты я привязал карту сербского банка (жили все лето в Сербии и на карте оставалось немного денег). Однако после изменения платежных данных на грузинские бан не сняли. Писал в поддержку, но объяснения так и не получил. Только пишут, чтобы не пытался создать другой аккаунт — его тоже забанят.

Решил использовать другую площадку.

Закинул игру в профильный чат, завел новые знакомства

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

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

Как устроен рынок гипер казуальных игр

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

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

  • Разработчик делает прототип игры.
  • Разработчик добавляет в прототип нужную издателю аналитику.
  • Издатель проверяет, что все аналитические события отправляются корректно и запускает маленькую рекламную кампанию игры за свой счет: несколько десятков долларов, пара дней рекламы.
  • По итогу кампании издатель считает CPI и Retention, чтобы понять потенциал игры. Абстрактно: насколько игра интересна со стороны (люди кликают по рекламе и устанавливают) и насколько игра интересна спустя время (как надолго новые пользователи задерживаются, как активно пользуются).
  • Дальше есть варианты:
    • Если игра показывает хорошие результаты — дешево привлечь новых пользователей, а старые задерживаются так, что на них можно заработать — издатель предлагает разработчику контракт. По условиям контракта издатель вкладывает деньги в рекламу, а прибыль сверх затрат на рекламу издатель и разработчик делят, скажем, 50/50. Читайте свой контракт. Пример: издатель влил в рекламу 100 тысяч долларов -> прибыль с этого, допустим, за месяц за всех пользователей, пришедших с рекламы — 150 тысяч долларов -> издатель забирает 100 тысяч как возврат за рекламу, а остаток вы делите пополам, т.е. ваших — 25 тысяч.
    • Если цифры после запуска проверочной кампании спорные, издатель на основе своего опыта дает рекомендации по улучшению игры. По мере реализации вами этих улучшений издатель вновь может попробовать протестировать игру новой маленькой рекламной кампанией.
    • Ну и если цифры объективно не очень, скорее всего, будет просто сухой отказ, совет попробовать другой проект.

    Из описания выше вместо слова «издатель», кажется, больше бы подошло определение «инвестор». Кто-то с деньгами, готовый инвестировать в вашу игру.

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

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

    Всегда держите в голове, что игра может провалиться и придется заниматься чем-то другим.

    На что жить, пока делаешь прототип для издателя?

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

    Сотрудничество с издателем: добавил аналитику (дни 23-29)

    В целом, я и сам мог посчитать и CPI, и Retention. И даже денег на маленькую рекламную кампанию мне было не жалко. Но учитывая, что Google забанил мой рекламный аккаунт, путь меньшего сопротивления был попробовать сотрудничество с издателем. Да и интересен был итоговый фидбек при любых результатах теста. Решил попробовать.

    Для издателя было необходимо:

    • Подключить в проект 2 аналитических SDK: AppMetrica и Game Analytics.
    • Реализовать отправку нескольких простых событий.
    • Для трекинга установок подключить SDK запрещенной в России социальной сети. Рекламную кампанию издатель запускал именно в этой сети.

    Всё заняло порядка четырех дней. Еще день на доработку креативов под формат рекламной площадки, т.к. требования отличались от Google Ads. Всё сделал, передал издателю.

    В ожидании фидбека издателя (дни 30-38)

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

    Перед стартом очередного спринта немного переосмыслил бэклог и стратегию: оформил аспекты игры, как некоторые стадии CJM — путь пользователя в игре с течением времени:

    С таким бэклогом стало проще фокусироваться на слабых местах в воронке пользователей:

    1. Если мы понимаем, что пользователи не скачивают приложение — проблема на стадии принятия решения, часто в визуале.
    2. Если пользователи скачивают игру, но быстро уходят — возможно, что-то не так с геймплеем и его подачей.
    3. Если же на предыдущих двух стадиях у проекта всё отлично, но долгосрочный Retention хромает — следует уже поработать над механиками прогресса, соревновательными и клановыми механиками, жизнью в background и т.д.

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

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

    На основном CJM я отмечал звёздочками реализуемые вещи, не заблокированные чем-либо. Затем отмеченные задачи переносил на две следующие плоскости:

    По оси Y на графиках польза и важность задач, у меня эти понятия практически всегда совпадали. По оси X на левом графике оценка усилий для реализации, а на правом — срочность. Оцениваешь задачи, становится очевидным, что сейчас самое срочное, важное и при этом требует адекватных усилий. Шаблоны в miro:

    • Левый график — Impact-Effort.
    • Правый график — Importance-Urgency.

    Как в итоге выглядел следующий спринт:

    Итоги спринта:
    • Появился полноценный симпатичный туториал. На клетках для рубки появились мишени. Оба улучшения призваны помочь новым пользователям сориентироваться в правилах игры.
    • Устаканились инструменты для планирования. Далее схему не менял.

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

    Музыкальное оформление (дни 40-50)

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

    За тот месяц, что игра в сторе, я набросал саундтрек на айпаде в бесплатной программе GarageBand. Штука крутая, рекомендую. Использовал китайский и японский набор инструментов, под стать визуальному оформлению.

    Интерфейс программы GarageBand

    Музыку выложил в open source, может кому-нибудь пригодится:

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

    Сами кнопки реализованы все в том же Blender, и импортированы в проект как 3D-модели. При клике кнопка поворачивается к пользователю другой стороной. А при множественном клике градус поворота накапливается и происходит ускорение. Мелочь, большинство пользователей вряд ли заметит, а мне нравится.

    • Выложил пакет 3D-моделей со скриптами в Unity Assets Store.
    • Исходники Blender моделей на github.
    • Отдельно скрипты для вращения 3D-объектов по клику.
    • Туториал как в Unity вставить 3D-объект в UI.

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

    Скрины планирования прилагать уже не буду. Вот таким получился спринт:

    Итоги спринта:
    • У противника в ассортименте появились слоны и кони.
    • Добавил музыкальное сопровождение.

    Результаты от издателя: CPI $3.26, отказ

    По ходу спринта заметил на графиках всплеск новых пользователей. Так я понял, что запустили рекламу. По данным Google Play, пришли 10 человек.

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

    В рекламной кампании крутилось 3 видеоролика. На каждом из них просто запись экрана во время игры. Мне рекомендовали играть для видео немного «тупя». Это должно было замотивировать потенциальных пользователей скачать игру и «показать, как надо». Кому показать? История умалчивает. Но чувство знакомое, видел подобную рекламу и также хотелось кому-то что-то показать.

    Мое личное видение игры — это что-то драйвовое, во что скучно играть тупя. Результаты по CPI это больше подтверждают, чем опровергают. Так для кампании я отдал издателю 3 ролика разного рода «сообразительности» игрока. Кодовые названия: Hard, Medium и Easy. От самого умелого пользователя до наименее умелого.

    Наилучший CPI =

    Мне сказали, $3.26 — это очень много. Т.е. новых пользователей привлекать очень дорого.

    По итогу рекламной кампании мне посоветовали попробовать вложить силы в другой проект.

    Тут можно, конечно, задать вопросы:

    • Можно ли доверять числам, полученным на такой небольшой выборке: всего 400 показов по ролику Hard?
    • Был ли какой-то таргетинг у рекламы? Например, на тех, у кого уже установлено одно из шахматных приложений.
    • Может быть рекламные креативы выглядели непривлекательно?

    В открытых исследованиях нашел, что в 2021 году году средний CPI для казуальных игр был в районе $2. При этом в Северной Америке CPI в среднем на уровне $6,36 для всех казуальных игр, а реклама от издателя как раз была направлена в основном на Штаты.

    Как бы то ни было, фидбек издателя — не приговор, и прежде чем сдаться я решил проверить еще пару гипотез:

    1. Попробовать запуститься на iOS с условной ценой за установку в $1. Посмотреть, насколько хорошо работает такая монетизация.
    2. Попробовать самостоятельно провести рекламную кампанию с новыми креативами. Пройти, так сказать, весь путь.

    Неудачная попытка запуститься на iOS

    Чтобы иметь возможность зарелизить приложение или игру на iOS, нужен Apple Developer Account. Регистрируется он в специальном приложении из AppStore.

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

    По сети видел подобные сообщения, решения — не видел. Других путей зарелизить на iOS не знаю. Видимо, не в этом году.

    Попутно еще посмотрел возможность зарелизить в какие-нибудь китайские магазины приложений. Google Play в Китае не доступен, а аудитория миллиардная. Все локальные магазины требуют наличие юрлица в Поднебесной. Для меня это пока тоже не вариант.

    Запуск собственной рекламной кампании

    Цели ставил такие:

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

    Google Ads отпал. Из других площадок выбрал Unity, как простейшую в оформлении: доступ к платформе осуществляется из личного кабинета Unity, который у меня уже был.

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

    Пополнение бюджета через PayPal как отдельное приключение

    Я был готов потратить до $100 на рекламу. Единственный возможный способ пополнить бюджет в Unity — это PayPal, американская компания, приостановившая регистрацию россиян в марте этого года. Но я же в Грузии, решил попробовать.

    При пополнении бюджета Unity перенаправляет на PayPal. Если нет аккаунта, предлагает зарегистрироваться. Но при регистрации для выбора нет как страны Russia, так и национальности Russian.

    Та же история, если пробовать с браузера на телефоне. Мобильное приложение не скачать, т.к. оно не распространяется в Грузии. Полагаю, не распространяется и в России.

    Но я нашел обходной путь. Дальше кейс из разряда «не повторяйте это дома»:

    1. Зашел на сайт PayPal напрямую, не по редиректу с Unity.
    2. Начал регистрацию по номеру телефона.
    3. Ввел грузинский номер, подтвердил смс-кодом — по сути аккаунт создан и верифицирован, нужно только дозаполнить кое-какие данные. Национальность, например.
    4. При дозаполнении данных существующего аккаунта уже есть опция Russian. Всё ввел, сохранил. Так далеко я еще не заходил.
    5. Указал для платежей сербскую карту.
    6. Не теряя времени, вернулся в Unity и пошел пополнять бюджет для рекламной кампании.
    7. Unity редиректит в PayPal.
    8. Куки говорят, что я уже авторизован — попал сразу на страничку оплаты.
    9. Выбрал карту, смс-код от банка. Пара секунд и деньги на моем счету в Unity.
    10. Профит.

    Буквально через 10 минут мне пришел email от PayPal, что из-за подозрительных транзакций действие моего аккаунта приостановлено. Попросили предоставить им дополнительные данные, среди которых паспорт. Всё предоставил, спустя две недели аккаунт все-таки разморозили. Но в тот момент рабочий PayPal мне уже был не нужен, средства были на аккаунте Unity, и рекламу я мог оплатить.

    1. Подготовил новые динамичные креативы
    2. Страна распространения: США
    3. Дневной бюджет: $50
    4. Бюджет всей кампании: $100
    5. Bid: $1
    6. Запустил

    4 дня кампании, разница между Bid $1 и $3

    Bid — это максимальная цена, которую я готов платить за каждый клик по моей рекламе. Unity рекомендует выставлять его как мой целевой CPI, выбрал $1. Рекламные алгоритмы стараются оптимизировать показы так, чтобы цель по CPI была достигнута. Всю кампанию я делю на два исторических этапа именно по тому, какой Bid я выставлял.

    График производительности кампании за три дня с Bid = $1:

    Показы, установки и затраты — это числа разного порядка. Для каждого показателя по оси Y шкала своего цвета.

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

    Запускал поздно вечером 7 ноября. Из первых четырех просмотров получилась одна установка. Перед сном у меня было приподнятое настроение. Но всё пришло в норму уже на следующее утро, где на 80 просмотров оставалась всё та же одна установка. На третий день больше всего показов, но вообще ни одной установки.

    Статистика за три дня:

    • Показов: 194
    • Установок: 2
    • Потрачено: $5.21
    • CPI: $2.6

    CPI уже чуть лучше, чем у издателя, но все равно хуже, чем нужно. При этом очень много времени занимает получение результатов, очень мало показов ежедневно — так влияет Bid. Решил ради эксперимента поднять Bid до $3.

    График за девятое и десятое ноября. Девятого Bid равен

    Показы и затраты значительно выросли, а вот установки — нет.

    Статистика за четвертый день:

    • Показов: 1806
    • Установок: 4
    • Потрачено: $46.3
    • CPI: $11.6

    Дорого. Кампанию остановил.

    На этом я оставил надежды на дальнейшее развитие проекта.

    Немного аналитики

    Все числа привожу на 18 ноября, в завершение работы над данной статьей.

    • От первой строчки кода до заморозки проекта прошло 2 месяца и 3 дня.
    • Рабочие дни от нерабочих отделить сложно, но суммарно дней 10 я вообще не прикасался к ноутбуку.
    • В среднем в неделю я тратил на проект 25-30 часов. Не помню ни дня, чтобы я сидел больше пяти часов.
    • По занятости я бы оценил так:
      • 30% — написание кода.
      • 30% — вся дизайнерская часть: иконки, баннеры, кнопки и т.д.
      • 20% — взаимодействие с издателем: внедрение аналитики, подготовка креативов.
      • 10% — создание саундтрека, написание музыки.
      • 10% — всё остальное: планирование, тестирование, работа с различными платформами.
      • Лучший CPI:
        • от издателя: $3.26
        • от своей кампании: $2.6
        • До пользователей доставлено 15 версий игры, из которых 3 — хотфиксы
        • Всего установок: 40
        • Среднее количество одновременно активных установок: ~12
        • Retention:
          • 1-2 дня: ~7%
          • 3-9 дней: ~3%
          • 10+ дней: около нуля

          Итог и дальнейшие планы

          Вначале у меня была цель заработать хотя бы 1 доллар. За 2 месяца получилось заработать почти 8 центов на рекламе.

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

          Точно ни о чем не жалею: отдохнул от «горящего энтерпрайза», приобрел новые интересные навыки, прикоснулся к самой творческой части IT-индустрии и довел игру до релиза. Даже выпустил десяток версий и улучшений.

          Полезные материалы

          Отдельно выделю то, что не вошло в основное повествование, но определенно повлияло на меня:

          • Выступление на конференции Джона Ромеро про историю становления id Software.
          • Большая серия коротких обучающих видео по Blender.
            • Особенно запомнилось видео про 3 точки света — технику, вероятно, знакомую всем фотографам и видеоблогерам.
            • Статья «13 главных ошибок, мешающих разрабатывать игры»
            • Статья «Анализ топа кассовых мобильных игр»
            • Статья «Правила создания free-to-play игр»
            • Топ издателей гиперказуальных игр
            • Отчёт по рынку казуальных игр за 2021-й год
            • Unity — движок для создания игр.
            • Blender — 3D-моделирование, анимация, дизайн.
            • Miro — доска для визуализации чего-угодно.
              • Шаблон для роадмапа.
              • Шаблон Impact-Effort.
              • Шаблон Importance-Urgency.
              • Unity
                • Cоздание UI.
                • Внедрение рекламы.
                • Автотесты.
                • Запись видео, например, для рекламной кампании .
                • Отдельная камера для UI.
                • Запуск рекламной кампании.
                • Выпиливаем доску.
                • Гравируем текст.
                • Процедурно генерируем древесный материал.
                  Safe Area Helper — размещение элементов UI с учитетом особенностей смартфонов с вырезами под камеры.

                3D вращающиеся кнопки

                • Unity Assets Store
                • Blender исходники
                • Скрипты для вращения 3D-объектов

                Ну и напоследок, главный герой статьи в Google Play. Как понимаете, на iOS поиграть пока не выйдет.

                На этом всё. Буду рад любому фидбеку. Спасибо за внимание.

                • Разработка игр
                • Unity
                • Монетизация игр
                • Дизайн игр
                • Продвижение игр

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *