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

в 8:01, , рубрики: 2d, android, bodyawm_ништячки, GAPI, Godot, java, just for fun, opengl es, timeweb_статьи, UE, unity, Urho, Windows Mobile, геймдев, игры
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-телефонов, графику обычно не делали адаптивной, из-за чего при смене разрешения, игровое поле могло растягиваться на всю площадь дисплея. Сейчас же, когда разброс разрешений стал колоссальным, чаще всего можно встретить два подхода к организацию проекции:

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

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

Заключение

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

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


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

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

Автор: Богдан

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js