Это гифка, которую я сделал, чтобы показать вступление и как началась история путешествия птички. У меня есть друг, который не боится рисовать, даже если он не обучался рисованию профессионально. Я общаясь с ним как то вдохновился желанием рисовать и не бояться. В google play у меня есть старая игра, которую я делал на unity, когда только начинал работать с движком.

https://play.google.com/store/apps/details?id=com.xverizex.fly_bird&hl=ru&gl=US

Два комментария к старой игре дали мне желание сделать новую версию, но уже на C++ + SDL2 + OPENGL ES 3.2 + OPENSLES + glm. То есть я даже рад хотя бы двум комментариям о том что людям нравиться моё творчество, чтобы чувствовать себя прекрасно и продолжать делать игры.

Так как у меня нормального опыта не было делать игры полноценные на sdl2, то я использовал разные виды кода, которые как я думал, что они правильные. Но поработав на работе и изучая код, я увидел что есть помимо того что я знаю (я про очереди сообщений), есть ещё mqueue. И только потом я додумался, что можно с помощью очередей сообщений отправлять из одного потока в другой что-нибудь. Вот пример как выглядела реализация.

/* SDL поток */
static int general_thread (void *p)
{
  SDL_Event ev;
  while (SDL_WaitEvent (&ev)) {
    case SDL_MOUSEBUTTON_DOWN:
    {
      struct event *event = new struct event();
      event->type = BUTTON_DOWN;
      event->x = ev.x;  // здесь я сократил код, по настоящему здесь надо
      event->y = ev.y;  // преобразовать в нужный формат.
      mq_send (mq, (const char *) &event, sizeof (void *), 0);
      break;
    }
  }
}

int main (int argc, char **argv) 
{
  ...
    game ();
}

/* И где то в другом файле где находится функция game */
void game ()
{
  ...
    mq_receive (mq, &event, nullptr);
}

Перед тем как использовать эту очередь, я удостоверился в том, что в android ndk есть заголовочный файл mqueue.

Я также посмотрел, есть ли OpenAL для android и оказалось, что она не входит в комплект и как почитал в интернете, что лучше писать для android на OpenSLES.

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

static void gen_vao_vbo (Link *link)
{
        static float v[18] = {
                -0.5f, -0.5f, 0.0f,
                -0.5f, 0.5f, 0.0f,
                0.5f, -0.5f, 0.0f,
                0.5f, -0.5f, 0.0f,
                0.5f, 0.5f, 0.0f,
                -0.5f, 0.5f, 0.0f
        };

#if 0
        static float t[12] = {
                0.0f, 1.0f,
                0.0f, 0.0f,
                1.0f, 1.0f,
                1.0f, 1.0f,
                1.0f, 0.0f,
                0.0f, 0.0f
        };
#else
        static float t[12] = {
                0.0f, 0.0f,
                0.0f, 1.0f,
                1.0f, 0.0f,
                1.0f, 0.0f,
                1.0f, 1.0f,
                0.0f, 1.0f
        };
#endif

Я сначала думал что в unity делают правильно, что отсчитывают от центра и исходил из этого и определял экран как.

ortho = glm::ortho (-1.0f * aspect, 1 * aspect, 1.0f, -1.0f, 0.1f, 10.0f);

вроде такой был код.

Позже я понял неудобство и решил отсчитывать от левого верхнего угла и сделал так.

ortho = glm::ortho (0.0f, 2.0f * aspect * aspect, 2.0f, 0.0f, 0.1f, 10.0f);

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

Sprite::Sprite (Common &com)
{
        aspect = (float) com.screen_width / (float) com.screen_height;
        screen_width = com.screen_width;
        screen_height = com.screen_height;
        ortho = glm::ortho (0.0f, 2.0f * aspect * aspect, 2.0f, 0.0f, 0.1f, 10.0f);
        //ortho = glm::ortho (0.0f, (float) com.screen_width, (float) com.screen_height, 0.0f, 0.1f, 10.0f);
        pos = glm::translate (glm::mat4 (1.0f), glm::vec3 (0.0f, 0.0f, 0.0f));

        program = get_shader (SHADER_MAIN);
        glUseProgram (program);

        uniform_cam = glGetUniformLocation (program, "cam");
        uniform_pos = glGetUniformLocation (program, "pos");
        uniform_ortho = glGetUniformLocation (program, "ortho");
        uniform_tex = glGetUniformLocation (program, "s_texture");

        cur_tex = 0;
        play = -1;
}

Вообще когда говорят что глобальные переменные это зло, то я думаю что они просто это от кого то услышали и приняли для себя такое же мнение, но мне например не удобно как оказалось передавать объект Common в конструктор. Лучше бы я просто пробросил с помощью extern размеры экрана и всё было бы чище. Да и ещё я по рассуждал, что можно для каждого шейдера отдельный класс создать, чтобы каждый спрайт заново не получал с помощью glGetUniformLocation позиции в шейдере. То есть после компиляции шейдера можно было бы получить все позиции и для спрайта указать например интерфейс к шейдеру или что нибудь подобное, чтобы просто уже было работать. Да и класс шейдера можно было бы интегрировать со спрайтом так, чтобы в рендере спрайта не менять ничего, если ты сменил шейдер. Хотя может я ошибаюсь, но я проработаю этот вопрос.

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

as = (float) com.screen_width / (float) com.screen_height;

/* если ширина больше */
float aspect = w / h;

w = 0.42f;
h = w / aspect / as;

/* если высота больше */
aspect = h / w;

h = 0.8f;
w = h * aspect;

Вроде бы получилось правильно.

Для загрузки объектов я создал заголовок такого типа.

#pragma once

#define TO_STRING_FILENAME(name) name##_STRING


enum TO_DOWNLOADS {
        LINK_BIRD,
        LINK_INTRO,
        LINK_BLOCK,
        LINK_FLY_BIRD,
        LINK_END_GAME,
        LINK_LOGO,
        LINKS_N
};

#ifdef __ANDROID__
#define LINK_BIRD_STRING                  "bird.res"
#define LINK_INTRO_STRING                 "intro.res"
#define LINK_BLOCK_STRING                 "block.res"
#define LINK_FLY_BIRD_STRING              "fly_bird.res"
#define LINK_END_GAME_STRING              "end_game.res"
#define LINK_LOGO_STRING                  "logo.res"
#else
#define LINK_BIRD_STRING                  "assets/bird.res"
#define LINK_INTRO_STRING                 "assets/intro.res"
#define LINK_BLOCK_STRING                 "assets/block.res"
#define LINK_FLY_BIRD_STRING              "assets/fly_bird.res"
#define LINK_END_GAME_STRING              "assets/end_game.res"
#define LINK_LOGO_STRING                  "assets/logo.res"
#endif

И если нужно загрузить какой то объект, то мы просто получаем на него ссылку, если он уже был загружен.

Link *downloader_load (const enum TO_DOWNLOADS file)
{
        switch (file) {
                case LINK_BIRD:
                        if (link[LINK_BIRD] == nullptr)
                                link[LINK_BIRD] = load_link (TO_STRING_FILENAME (LINK_BIRD));
                        break;
                case LINK_INTRO:
                        if (link[LINK_INTRO] == nullptr)
                                link[LINK_INTRO] = load_link (TO_STRING_FILENAME (LINK_INTRO));
                        break;
                case LINK_BLOCK:
                        if (link[LINK_BLOCK] == nullptr)
                                link[LINK_BLOCK] = load_link (TO_STRING_FILENAME (LINK_BLOCK));
                        break;
                case LINK_FLY_BIRD:
                        if (link[LINK_FLY_BIRD] == nullptr)
                                link[LINK_FLY_BIRD] = load_link (TO_STRING_FILENAME (LINK_FLY_BIRD));
                        break;
                case LINK_END_GAME:
                        if (link[LINK_END_GAME] == nullptr)
                                link[LINK_END_GAME] = load_link (TO_STRING_FILENAME (LINK_END_GAME));
                        break;
                case LINK_LOGO:
                        if (link[LINK_LOGO] == nullptr)
                                link[LINK_LOGO] = load_link (TO_STRING_FILENAME (LINK_LOGO));
                        break;
        }

        return link[file];
}

Да, можно было с помощью текста указывать какой объект загружать, но мне так больше нравиться, и нравиться еще из-за того, что легко получить эту ссылку на объект, если он уже был загружен. В link содержится все vao, vbo[2] и номера всех текстур.

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

void Sprite::mirror_right ()
{
        glBindVertexArray (link->vao);
        glBindBuffer (GL_ARRAY_BUFFER, link->vbo[0]);
        float *b = (float *) glMapBufferRange (GL_ARRAY_BUFFER, 0, sizeof (float) * 18, GL_MAP_WRITE_BIT);

        float ww = w;
        float hh = h;

        b[0] = 0;
        b[1] = 0;
        b[2] = 0;
        b[3] = 0;
        b[4] = hh;
        b[5] = 0;
        b[6] = ww;
        b[7] = 0;
        b[8] = 0;
        b[9] = ww;
        b[10] = 0;
        b[11] = 0;
        b[12] = ww;
        b[13] = hh;
        b[14] = 0;
        b[15] = 0;
        b[16] = hh;
        b[17] = 0;

        glUnmapBuffer (GL_ARRAY_BUFFER);
}

void Sprite::mirror_left ()
{
        glBindVertexArray (link->vao);
        glBindBuffer (GL_ARRAY_BUFFER, link->vbo[0]);
        float *b = (float *) glMapBufferRange (GL_ARRAY_BUFFER, 0, sizeof (float) * 18, GL_MAP_WRITE_BIT);

        float ww = w;
        float hh = h;

        b[0] = ww;
        b[1] = 0;
        b[2] = 0;
        b[3] = ww;
        b[4] = hh;
        b[5] = 0;
        b[6] = 0;
        b[7] = 0;
        b[8] = 0;
        b[9] = 0;
        b[10] = 0;
        b[11] = 0;
        b[12] = 0;
        b[13] = hh;
        b[14] = 0;
        b[15] = ww;
        b[16] = hh;
        b[17] = 0;

        glUnmapBuffer (GL_ARRAY_BUFFER);
}

Оказалось не так уж и сложно отражать объект. Также можно отразить по вертикали, например поменяв местами координаты текстуры.

По OpenAL писать нечего, я сделал музыку специально для 44100 частоты и 16 битного формата вроде. По OpenSLES я скачал спецификацию и почитал немного, понял что надо посмотреть примеры реализации и банально переписал код, чтобы заработало на android.

При портировании на android как оказалось, что там нет mqueue реализации. Я нашел только syscall от ядра linux. Но если был syscall для открытия mq_open, то syscall для отправки не было и я подумал что надо искать другое решение. Так как я больше на C писал и на C++ опыта мало, то я конечно же не знал, что в C++ есть контейнер queue. И это было спасением, я сделал её глобальной рядом с функцией main и sdl потоке отправлял в нее event. А в game () файле я пробросил queue с помощью extern и получал события. И вуаля, всё работает.

Так как архитектуры различны, то я просто в ресурс добавил число 1. Если при прочитывании этой переменной, она не равно единице, то делаем смену из littleEngian в bigEngian.

static int swap_little_big_engian (int num)
{
        return (((num >> 24) & 0xff) | ((num << 8) & 0xff0000) | ((num >> 8) & 0xff00) | ((num << 24) & 0xff000000));
}

static uint8_t **diff_file_to_textures (Link *link, const char *filename)
{
        int lb = 0;

        SDL_RWops *io = SDL_RWFromFile (filename, "rb");
        SDL_RWseek (io, 0, RW_SEEK_END);
        long pos = SDL_RWtell (io);
        SDL_RWseek (io, 0, RW_SEEK_SET);
        uint8_t *file = new uint8_t[pos];
        SDL_RWread (io, file, pos, 1);
        SDL_RWclose (io);

        const int LTBE = 0;
        const int COUNT = 1;
        const int WIDTH = 2;
        const int HEIGHT = 3;
        int *pack[4];

        for (int i = 0; i < 4; i++) {
                pack[i] = (int *) &file[i * 4];
        }

        if (*pack[LTBE] != 1) {
                for (int i = 1; i < 4; i++) {
                        *pack[i] = swap_little_big_engian (*pack[i]);
                }
        }

        link->size_tex = *pack[COUNT];
        link->width = *pack[WIDTH];
        link->height = *pack[HEIGHT];
...

Насчет шрифта freetype2. Я использовал старую сборку freetype, которая у меня на github, потому что новую так и не смог собрать для android.

Также, чтобы скомпилировать с OpenGLESv3, надо обратить внимание, что в ndk библиотеки с такой версией есть не ниже 18 api. Чтобы решить все проблемы с компиляцией, нужно в каталоге app в файле build.gradle сделать типа такого.

android {
    compileSdkVersion 31
    defaultConfig {
        if (buildAsApplication) {
            applicationId "com.xverizex.fly_bird_2"
        }
        minSdkVersion 18
        targetSdkVersion 31
          ...
                      ndkBuild {
                arguments "APP_PLATFORM=android-18"
                abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
            }

Важно в ndkBuild тоже указать платформу назначение и тогда компиляция сработает.

Ну и указать в app/jni/Application.mk версию api не забыть.

Учитывая прошлый опыт, я не стал на каждую игру заводить отдельный паблик, а сделал один основной и назвал - игры от xverizex.

https://vk.com/xverizex_games

Игра, которую я написал, можно найти по кодовому названию в google play.

com.xverizex.fly_bird_2

Правда я всё ещё жду пока одобрят первую версию и пока она не доступна в маркете. Я хочу сделать её бесплатной в google play, а в huawei маркете, если это вообще возможно, то выставить цену на игру. Хотелось бы ещё зарабатывать на том что нравиться.

Игра по своей сути получилась относительно простой и поэтому её возможно было сделать за 5 дней. Да, на unity можно было бы за дня два или один сделать, но мне нравиться C и C++, разумеется я буду писать на том что мне нравиться.

Это были мои все заметки, которые я запомнил за прошедшие пять дней разработки. Я писал по 12 или более часов почти каждый день и не мог уснуть, потому что было интересно. Но теперь нужно отдохнуть перед следующим заходом. Возможно новый уровень в этой игре или новая игра.

Комментарии (5)


  1. vesper-bot
    23.04.2022 17:57

    На редкость сумбурно, аж пролистал 80% текста без вникания в него. Упомянуты очереди — хорошо, а как они применены в случае требования синхронизованного с отображением процесса обработки нажатий куда-то там? Упомянуты шейдеры — хорошо, но тут же идет работа с какими-то структурами типа array of int, без объяснения, что это и нафига. Упомянуты глобальные переменные — так скажем плохо, запахло антипаттерном God Object. И всё это перемешано с фразами вроде "при работе с Х нужен апи версии У", которые хотя и могут кому-то пригодиться, но в публичном техническом тексте как минимум мешаются.


  1. Wolf4D
    23.04.2022 17:59
    +1

    Автор, за попытку что-то изучать - огромный плюс! За статью - условный минус (без занесения в карму).

    Почему?

    1. Очень плох русский язык, простите. Текст кишит "проезжая мимо станции, у меня слетела шляпа", ага. Местами как будто даже в Ворде не проверяли.

    2. Важность придаётся каким-то случайным моментам, часто связанным с вашими личными переживаниями. Особенно ломают мозг такие предложения, как: "я увидел что есть помимо того что я знаю (я про очереди сообщений), есть ещё mqueue".
      Никто из присутствующих не знает ни вас лично, ни объёма ваших знаний. Как говорил один мой наставник, "забудьте слово Я, в науке ваши эмоции на пути к решению проблемы - это факт вашей личной биографии". Чтобы интересно рассказать о пути решения проблемы, надо провести какое-то внятное сравнение, анализ.

    3. Кидать куски кода в статью - моветон. Это приемлемо разве что в обучающих статьях, и то очень осторожно. Тем более, в коде вбиты какие-то константы, он специфичен для вашей игры - думаете, кто-то ещё сможет его применить?

    4. Почему-то невежество в некоторых вопросах вы предъявляете в качестве доблести. Как, например, про глобальные переменные. Раз всё просвещённое человечество пришло к такому умозаключению - вероятно, оно имело на то некоторые соображения? Вы в праве с ними не соглашаться - но тогда докажите, чем позиция классиков плоха :)

    5. Код. Ну, придираться не буду, все мы так делали. Но упомяну:

    • Что за шаманство с подгонкой спрайтов? Должны быть готовые решения. А иначе стоит вашей игре попасть на устройство с другой версией ОС / диагональю / dpi - и всё намертво сломается.

    • Для определения endianness, а также для правильного чтения ресурсов с его учётом, есть широкое множество библиотек.

    1. В статье нет видео с результатом. Пришлось сходить в паблик.

    2. Ну и вкусовщина. Стартовый экран выглядит хорошо, интересно, приятно. Стильно. Графика геймплея - очень скудная. Почему нельзя было сделать такой всю игру? :)


    1. xverizex Автор
      23.04.2022 19:09

      Отвечу на 7 вопрос, хотя он наверное является риторическим. )

      В gameplay сложно придумать что-то такое, чтобы не отвлекало от процесса. Здесь важна концентрация как я считаю, а не разглядывание разным спрайтов.

      Ладно, попробую ответить еще на 4 вопрос.

      глобальные переменные, это как общие переменные для всей программы. То есть чтобы не писать в одном файле весь код, мы разделяем его на несколько файлов, но так как нам нужны переменные, которые были объявлены в начале файла, то будет удобней просто прокидывать их в нужный файл с помощью extern. Давайте посмотрим один из примеров, который мне сейчас в голову пришел. Объявим массив классов.

      Sprite *sp = new Sprite[10];

      В данном случае мы не можем сразу в конструктор передать какие-либо переменные. Глобальные переменные с помощью extern дают нам возможность использовать любой набор данных и они общие для всех. Я люблю использовать глобальные переменные, просто в этом проекте я решил попробовать не использовать их и нарвался на неудобства. Главное те что нельзя передавать в другие файлы, надо помечать как static. Вообще использования глобальных переменных это особый стиль, важно попробовать и то и то и убедиться самому что тебе больше подходит. Да, возможно если ты пишешь библиотеку для общего пользования, то глобальные переменные не должны иметь силу, чтобы не путать с кодом программиста. Но если у тебя свое приложение, а не библиотека, то это приобретает другой смысл. Я уже пописать успел и так и так и знаю что больше удобно. Ну вот скажи, чем плохи глобальные переменные в своем приложении, если это не библиотека?


      1. Wolf4D
        23.04.2022 23:42

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

        Программист Петя объявил у себя глобальную переменную X. Пусть, скажем, это текущее поведение монстра в игре. У себя в коде он чётко знает, каким кодом переменная устанавливается и каким кодом она изменяется.

        После этого пришёл программист Вася, который должен дописать "стадный инстинкт" монстрам. Он смотрит - опа, где-то среди 10 тысяч строк кода лежит такая вся невинная переменная. А давай я напишу код, который сам будет в неё ставить что нужно.

        Пришёл Дима. Теперь ему поручили написать код, отвечающий за управление монстрами под контролем игрока. Он взял и дописал к 20 тысячам строк кода ещё несколько мест, где переменная изменяется. Часть из них до обновления переменной кодом Васи.

        Пришёл Коля. Он решил, что всё работает очень медленно, и давайте-ка сделаем многопоточность. В итоге часть решений, связанных с управлением монстром, он вынес в отдельные потоки. Теперь переменная может ОДНОВРЕМЕННО изменяться из нескольких разных мест.

        Вопрос - на каком этапе проекту настанет капут? Вероятнее всего, на этапе Васи ещё можно будет разобраться, где и что изменяется, а вот до Коли "доедут одни только уши". Между прочим, случаи ни фига не выдуманные (:

        А если в процессе мы ещё и управляем памятью?..

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

        P.S. Пример вообще не понял. Если нужно создать разом много классов - то можно создать фабрику, которая удобно их сконструирует. Или сконструировать их через foreach. Или ещё пол-дюжины методов на любой вкус и стандарт компилятора. И каждый, каждый из этих методов будет безопасен, легитимен и получит одобрение самого взыскательного критика.


        1. xverizex Автор
          24.04.2022 09:28

          Не, ну вы каких то программистов описываете, совсем тупых. Конечно надо учитывать несколько факторов. Вы пытаетесь найти довод в каком то случае, в котором не приемлемо употреблять глобальные переменные и выбираете для них самый не правильный способ. Надо грамотно подходить к процессу. Например, глобальную переменную указывать как текущий размер экрана, то есть такие общие, которые должны влиять на все места, если они изменяться. Ну вот даже скорость игры, которая должна иногда увеличиваться. У меня например за все квадраты отвечает один спрайт и он начинает рисовать прямо за экраном и так до конца экрана рисует, потом меняет позицию. Если были бы мобы в игре, то мне бы при смене скорости нужно было бы всех мобов в цикле поменять скорость перемещения (то есть как по вашей логике), хотя по вашей логике им можно сделать указатель на эту переменную. ну короче. Если есть глобальная переменная ускорения, то поменяв её, она повлияет на всю игру. Да, те программисты их вашего примера определенно накосячили бы, но так как я объясняю будет намного лучше код пониматься. То есть главный например класс, он меняет скорость игры и всё. Зачем программистам менять скорость в другом месте? Если будут ссылки, то поменяв ссылку, то же самое будет, хотя её можно установить как const для спрайтов. Ну это как я размышляю. Может я и не настолько ещё профессионал, но я уж сам разберусь где использовать глобальные переменные и где нет.

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