Сам написал, сам поиграл: как я написал 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 в ряд, то нас ждут либо вылеты, либо дикие тормоза.

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

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

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

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

image

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

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

  • Основная суть: Вид сверху, человечком по центру экрана можно управлять и стрелять во вражин. Цель заключается в том, чтобы набрать как можно больше очков перед тем, как игрока загрызут. За каждого поверженного врага начисляются баксы, за которые можно купить новые пушки!
  • Оружие: Несколько видов вооружения, в том числе пистолеты, дробовики, автоматы и даже пулеметы! Всё оружие можно купить в внутриигровом магазине за валюту, которую игрок заработал во время игры
  • Враги: Два типа врагов — обычный зомби и «шустрик». Враги спавнятся в заранее предусмотренных точках и начинают идти (или бежать) в сторону игрока с целью побить его.
  • Уровни: Можно сказать, простые декорации — на момент написания статьи без какого либо интерактива.

Поскольку игра пишется с нуля, необходимо сразу продумать необходимые для реализации модули:

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

Поэтому, с учетом требований описанных выше, наша игра будет работать практически на любых смартфонах/планшетах/тв-приставках кроме китайских смартфонов на базе чипсета MT6516 без GPU из 2010-2011 годов. На всех остальных устройствах, включая самый первый Android-смартфон, игра должна работать без проблем. А вот и парк устройств, на которых мы будем тестировать нашу игру:

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

  • Установка ортографической матрицы в фиксированные размеры: Если координатная система уже была завязана на пиксели, или по какой-то причине хочется использовать именно её, то можно просто завязать игру на определенном разрешении (например, 480×320, или 480×800). Растеризатор формально не оперирует с пикселями — у него есть нормализованные координаты -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, из-за этого можно запросто накосячить и долго думать «а почему у меня буферы заполнены правильно, но геометрии на экране нет!?».

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() {             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;     } }

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

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

Пишем игру

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<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;     }

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

Заключение

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

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


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


ссылка на оригинал статьи https://habr.com/ru/articles/754588/


Комментарии

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

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