В комментах к предыдущей статье я обещал после релиза игры написать её детальный разбор. 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, где бактерия собирает другие бактерии и растёт. Решили делать подобную, но с атомом, который присоединяет другие атомы и вырастает до Вселенной. Но мы спеклись — все без опыта, никто не умеет работать в команде. И не было драйвера — только идея. Я предлагал сделать свой «кораблик», но никто не согласился.
Как сделаю в следующий раз
Если ещё столкнусь с подобным проектом, то сразу буду делать правильно. На мой взгляд, этапы разработки должны быть такие:
- Анализ рынка и идея. Что первично, наверное, зависит от типа игры. Если это коммерческая движуха, то сначала маркетинговое исследование, а потом идея.
- Анализ ресурсов. Теперь нужно обсудить плюсы, минусы, корнер-кейсы, сложности идеи и понять сроки реализации в зависимости от ресурсов. Проанализировать себя или команду. Готовы ли все, например, год по 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"
Понравилась статья? Подпишитесь на канал, чтобы быть в курсе самых интересных материалов
Подписаться