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

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

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

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

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

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

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

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

Подобные инструменты включают в себя как довольно функциональные конструкторы игр, которые обычно не требуют серьёзных навыков программирования и позволяют собирать игру из логических блоков, так и полноценных игровых движков на манер 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-смартфон, игра должна работать без проблем. А вот и парк устройств, на которых мы будем тестировать нашу игру:

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

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

❯ Рендерер

Начинаем, мы конечно же, с инициализации контекста 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, из-за этого можно запросто накосячить и долго думать «а почему у меня буферы заполнены правильно, но геометрии на экране нет!?».

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

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

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

Для загрузки спрайтов используется встроенный в 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-потока в новый и новый буфер…

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

Полная реализация звукового потока выглядит так. И да, с 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; } }

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

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

❯ Пишем игру

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

Писать игру я начал с создания первого уровня и реализации загрузчика уровней. В качестве редактора, я выбрал популярный и широко-известныйTileEd— удобный редактор с возможностью экспорта карт в несколько разных форматов. Я лично выбрал 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; }
Сам написал, сам поиграл: Как я написал 2D-игру для Android полностью с нуля, весом менее 1мб?

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

Сам написал, сам поиграл: Как я написал 2D-игру для Android полностью с нуля, весом менее 1мб?
@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) работает довольно неплохо на всех устройствах, которые были выбраны для тестирования. Однако это ещё не всё — предстоит добавить конечную цель игры (набор очков), магазин стволов и разные типы мобов. Благо, это всё реализовать уже совсем несложно :)

❯ Заключение

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

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

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

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

177177
49 комментариев

Чзх? Геймдев контент на порносайте?

21
Ответить

А мог бы портировать классику, совместив лучшее из двух миров.

17
Ответить

Чел пишет на старый андройд, это уже порно.

10
Ответить

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

14
Ответить

Комментарий недоступен

2
Ответить

Комментарий недоступен

9
Ответить

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

5
Ответить