Многие программисты так или иначе имеют тягу и интерес к разработке игр. Немалое количество спецов было замечено за написанием маленьких и миленьких игрушек, которые были разработаны за короткое время «just for fun». Большинству разработчиков за счастье взять готовый игровой движок по типу Unity/UE и попытаться создать что-то своё с их помощью, особенно упорные изучают и пытаются что-то сделать в экзотических движках типа Godot/Urho, а совсем прожжённые ребята любят писать игрушки… с нуля. Таковым любителем писать все сам оказался и я. И в один день мне просто захотелось написать что-нибудь прикольное, мобильное и обязательно — двадэшное! В этой статье вы узнаете про: написание производительного 2D-рендерера с нуля на базе OpenGL ES, обработку «сырого» ввода в мобильных играх, организацию архитектуры и игровой логики и адаптация игры под любые устройства. Интересно? Тогда жду вас в статье!
❯ Как это работает?
Конечно же разработка собственных игр с нуля — это довольно веселое и увлекательное занятие само по себе. Ведь удовольствие получает не только пользователь, который играет в уже готовую игру, но и её разработчик в процессе реализации своего проекта. В геймдеве есть множество различных и интересных задач, в том числе — и для программиста.
Один из прошлых проектов — 3D шутэмап под… коммуникаторы с Windows Mobile без видеоускорителей! Игра отлично работала и на HTC Gene, и на QTek S110!
В больших студиях принято всю нагрузку распределять на целые команды разработчиков. Артовики занимаются графикой, звуковики — музыкой и звуковыми эффектами, геймдизайнеры — продумывают мир и геймплей будущей игры, а программисты — воплощают всё это в жизнь. Однако, за последние 20 лет появилось довольно большое количество бесплатных инструментов, благодаря которым маленькие команды или даже разработчики-одиночки могут разрабатывать собственные игры сами!
Подобные инструменты включают в себя как довольно функциональные конструкторы игр, которые обычно не требуют серьёзных навыков программирования и позволяют собирать игру из логических блоков, так и полноценных игровых движков на манер Unity или Unreal Engine, которые позволяют разработчикам писать игры и продумывать их архитектуру самим. Можно сказать что именно «благодаря» доступности подобных инструментов мы можем видеть текущую ситуацию на рынке мобильных игр, где балом правят очень простые и маленькие донатные игрушки, называемые гиперкежуалом.
Но у подобных инструментов есть несколько минусов, которые банально не позволяют их использовать в реализации некоторых проектов:
- Большой вес приложения: При сборке, Unity и UE создают достаточно объёмные пакеты из-за большого количества зависимостей. Таким образом, даже пустой проект может спокойно весить 50-100 мегабайт.
- Неоптимальная производительность: И у Unity, и у UE очень комплексные и сложные рендереры «под капотом». Если сейчас купить дешевый смартфон за 3-4 тысячи рублей и попытаться на него накатить какой-нибудь 3 в ряд, то нас ждут либо вылеты, либо дикие тормоза.
Лично я для себя приметил ещё один минус — невозможность деплоить игры на устройства с старыми версиями Android, но это, опять же, моя личная хотелка.
Поэтому когда мне в голову пришла мысль сделать игрушку, я решил написать её с нуля — не используя никаких готовых движков, а реализовав всё сам — и игровую логику, и сам «движок» (правильнее сказать фреймворк). Не сказать, что в этом есть что-то очень сложное — в геймдеве есть отдельная каста «отшельников», которые называют себя «движкописателями» и пишут либо движки, либо игры — правда, не всегда хотя-бы одна игра доходит до релиза.
❯ Определяемся с задачами
Перед тем, как садится и пилить игрушку, нужно сразу же определится с целями и поставить перед собой задачи — какой стек технологий мы будет использовать, как будем организовать игровую логику, на каких устройствах игра должна работать и.т.п. Я прикинул и решил реализовать что-то совсем несложное, но при этом достаточно динамичное и забавное… 2D-шутер с видом сверху!
Игра будет написана полностью на Java — родном языке для Android-приложений. Пустые пакеты без зависимостей весят всего около 20 килобайт — что только нам на руку! Ни AppCompat, ни какие либо ещё библиотеки мы использовать не будем — нам нужен минимальный размер из возможных!
Итак, что должно быть в нашей игре:
- Основная суть: Вид сверху, человечком по центру экрана можно управлять и стрелять во вражин. Цель заключается в том, чтобы набрать как можно больше очков перед тем, как игрока загрызут. За каждого поверженного врага начисляются баксы, за которые можно купить новые пушки!
- Оружие: Несколько видов вооружения, в том числе пистолеты, дробовики, автоматы и даже пулеметы! Всё оружие можно купить в внутриигровом магазине за валюту, которую игрок заработал во время игры
- Враги: Два типа врагов — обычный зомби и «шустрик». Враги спавнятся в заранее предусмотренных точках и начинают идти (или бежать) в сторону игрока с целью побить его.
- Уровни: Можно сказать, простые декорации — на момент написания статьи без какого либо интерактива.
Поскольку игра пишется с нуля, необходимо сразу продумать необходимые для реализации модули:
- Графика: Аппаратно-ускоренный рендерер полупрозрачных 2D-спрайтов с возможность аффинных трансформаций (поворот/масштаб/искривление и.т.п). На мобильных устройствах нужно поддерживать число DIP'ов (вызовов отрисовки) как можно ниже — для этого используется техника батчинга. Сам рендерер работает на базе OpenGLES 1.1 — т.е чистый FFP.
- Ввод: Обработка тачскрина и геймпадов. Оба способа ввода очень легко реализовать на Android — для тачскрина нам достаточно повесить onTouchListener на окно нашей игры, а для обработки кнопок — ловить события onKeyListener и сопоставлять коды кнопок с кнопками нашего виртуального геймпада.
- Звук: Воспроизведение как «маленьких» звуков, которые можно загрузить целиком в память (выстрелы, звуки шагов и… т.п), так и музыки/эмбиента, которые нужно стримить из физического носителя. Тут практически всю работу делает за нас сам Android, для звуков есть класс — SoundPool (который, тем не менее, не умеет сообщать о статусе проигрывания звука), для музыки — MediaPlayer. Есть возможность проигрывать PCM-сэмплы напрямую, чем я и воспользовался изначально, но с ним есть проблемы.
- «Физика»: Я не зря взял этот пункт в кавычки. :) По сути, вся физика у нас — это один метод для определения AABB (пересечения прямоугольник с прямоугольником). Всё, ни о какой настоящей физике и речи не идет. :)
Поэтому, с учетом требований описанных выше, наша игра будет работать практически на любых смартфонах/планшетах/тв-приставках кроме китайских смартфонов на базе чипсета MT6516 без GPU из 2010-2011 годов. На всех остальных устройствах, включая самый первый Android-смартфон, игра должна работать без проблем. А вот и парк устройств, на которых мы будем тестировать нашу игру:
С целями определились, самое время переходить к практической реализации игры! По сути, её разработка заняла у меня около дву-трех дней — это с учетом написания фреймворка. Но и сама игра совсем несложная :)
❯ Рендерер
Начинаем, мы конечно же, с инициализации контекста 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) {
vPosBuf.putFloat(x);
vPosBuf.putFloat(y);
vColBuf.putFloat(col.R);
vColBuf.putFloat(col.G);
vColBuf.putFloat(col.B);
vColBuf.putFloat(col.A);
vUVBuf.putFloat(u);
vUVBuf.putFloat(v);
}
Всё более чем понятно — преобразуем координаты спрайта из 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, из-за этого можно запросто накосячить и долго думать «а почему у меня буферы заполнены правильно, но геометрии на экране нет!?».
В процессе разработки игры, при отрисовке относительно небольшой карты с большим количеством тайлов, количество вызовов отрисовки возросло аж до 600, из-за чего FPS в игре очень сильно просел. Связано это с тем, что на старых мобильных GPU каждый вызов отрисовки означал пересылку состояния сцены видеочипу, из-за чего мы получали лаги. Фиксится это довольно просто: реализацией батчинга — специальной техники, которая «сшивает» большое количество спрайтов с одной текстурой в один и позволяет отрисовать хоть 1000, хоть 100000 спрайтов в один проход! Есть два вида батчинга, статический — когда объекты «сшиваются» при загрузке карты/в процессе компиляции игры (привет Unity) и динамический — когда объекты сшиваются прямо на лету (тоже привет Unity). На более современных мобильных GPU с поддержкой GLES 3.0 есть также инстансинг — схожая технология, но реализуемая прямо на GPU. Суть её в том, что мы передаём в шейдер параметры объектов, которые мы хотим отрисовать (матрицу, настройки материала и.т.п) и просим видеочип отрисовать одну и ту же геометрию, допустим, 15 раз. Каждая итерация отрисовки геометрии будет увеличивать счетчик gl_InstanceID на один, благодаря чему мы сможем расставить все модельки на свои места! Но тут уж справедливости ради стоит сказать, что в D3D10+ можно вообще стейты передавать на видеокарту «пачками», что здорово снижает оверхед одного вызова отрисовки.
Для загрузки спрайтов используется встроенный в 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-потока в новый и новый буфер…
Полная реализация звукового потока выглядит так. И да, с SoundPool нет возможности получить позицию проигрывания звука или узнать, когда проигрывание закончилось. Увы.
public static class Instance {
private AudioStream parent;
private int id;
Instance(AudioStream parent) {
this.parent = parent;
}
public void play() {
id = sharedPool.play(parent.streamId, 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;
}
}
Кроме того, я добавил вспомогательный метод для вызова диалога ввода текста — это для таблицы рекордов и прочих фишек, которые требуют ввода текста пользователем. Ну не будем же мы сами клавиатуру костылить!
Основа для игры есть, теперь переходим к её реализации!
❯ Пишем игру
Писать игру я начал с создания первого уровня и реализации загрузчика уровней. В качестве редактора, я выбрал популярный и широко-известный 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<Sprite, Graphics2D.StaticBatch>();
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;
}
Список доступных стволов хранится в статическом массиве 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, от которого наследуются все монстры и который реализует несколько необходимых методов — повернуться в сторону игрока, идти вперед и конечно же атака!
@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) работает довольно неплохо на всех устройствах, которые были выбраны для тестирования. Однако это ещё не всё — предстоит добавить конечную цель игры (набор очков), магазин стволов и разные типы мобов. Благо, это всё реализовать уже совсем несложно. :)
❯ Заключение
Написать небольшую игрушку с нуля в одиночку вполне реально. Разработка достаточно больших проектов конечно же требует довольно больших человекочасов, однако реализовать что-то своё, маленькое может и самому!
Пишите своё мнение в комментариях. Если вам вдруг интересна тематика самопальной разработки игр, то постараюсь выпускать подобные статьи почаще!
Автор: Богдан