Этот текст предназначен для тех, кто только осваивает программирование. Я читаю лекции по C++ на первом курсе местного университета, и в качестве практикума предлагаю запрограммировать любую игру (не выношу проектов типа "софт бронирования книг в местной библиотеке"). Соответственно, чтобы помочь начинающим, я сделал некоторое количество заготовок, с которых можно стартовать свой проект. Например, заготовку олдскульного 3д шутера в 486 строк C++ я уже описывал, а вот тут можно посмотреть, что из неё сделали первокурсники.


В этот раз всё будет ещё проще, я хочу сделать заготовку под простейший платформер, вот так выглядит результат:



На данный момент проект содержит менее трёхсот строчек цпп:


ssloy@khronos:~/sdl2-demo/src$ cat *.cpp *.h | wc -l
296

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


Итак, поехали!


Шаг первый: компилируем проект и открываем окно


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


image


Изначально я хотел сделать черновой репозиторий, и потом почистить историю, убрать детские баги, сделать красивый "один коммит на одну фичу", но тут случился анекдот: только я создал репозиторий, как он был немедленно форкнут парой человек, один из которых мне к тому же прислал пулл реквест . Соответственно, чистить историю, не сломав их репы, я уже не могу. Издержки двух с половиной тысяч фолловеров на гитхабе . Таким образом, у меня честный репозиторий без прикрас.


Сборочный файл CMakeLists.txt лучше взять из последней версии, он линкуется с SDL2, если найдёт его в системе, а в противном случае подтягивает его исходники, и компилирует его сам. Должно работать без сучка-задоринки как под линухом, так и под виндой.


Вот так выглядит код, открывающий пустое окно:


#include <iostream>
#define SDL_MAIN_HANDLED
#include <SDL.h>

void main_loop(SDL_Renderer *renderer) {
    while (1) { // main game loop
        SDL_Event event; // handle window closing
        if (SDL_PollEvent(&event) && (SDL_QUIT==event.type || (SDL_KEYDOWN==event.type && SDLK_ESCAPE==event.key.keysym.sym)))
            break; // quit
        SDL_RenderClear(renderer); // re-draw the window
        SDL_RenderPresent(renderer);
    }
}

int main() {
    SDL_SetMainReady(); // tell SDL that we handle main() function ourselves, comes with the SDL_MAIN_HANDLED macro
    if (SDL_Init(SDL_INIT_VIDEO)) {
        std::cerr << "Failed to initialize SDL: " << SDL_GetError() << std::endl;
        return -1;
    }

    SDL_Window   *window   = nullptr;
    SDL_Renderer *renderer = nullptr;
    if (SDL_CreateWindowAndRenderer(1024, 768, SDL_WINDOW_SHOWN | SDL_WINDOW_INPUT_FOCUS, &window, &renderer)) {
        std::cerr << "Failed to create window and renderer: " << SDL_GetError() << std::endl;
        return -1;
    }
    SDL_SetWindowTitle(window, "SDL2 game blank");
    SDL_SetRenderDrawColor(renderer, 210, 255, 179, 255);

    main_loop(renderer); // all interesting things happen here

    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

Собственно, там ничего сверхъестественного: мы инициализируем SDL, создаём окно с зоной рендера и запускаем основной цикл игры. По событию закрытия окна или по нажатию эскейпа выходим из цикла и чистим память. Piece of cake.


Шаг второй: счётчик fps


На втором этапе я решил отобразить количество перерисовок экрана в секунду, хочу увидеть вот такой результат (не пугайтесь сорока тысяч fps, всё же мы ничего не делаем в основном цикле!):



Для этого нам нужно две вещи:


  • научиться считать количество перерисовок экрана в секунду
  • научиться его отображать на экране

Давайте начнём с отрисовки счётчика. Сперва я хотел рендерить текст при помощи библиотеки SDL_ttf, но потом выяснилось, что она тянет за собой ещё и другие зависимости, и мне стало лень автоматически собирать ещё и их, если они не установлены в системе. Поэтому я решил сделать существенно тупее: я нарисовал десять цифр размера 24x30 пикселей, и упаковал их в один .bmp файл размера 240 x 30 пикселей:



Всё едино мне нужно будет уметь работать со спрайтами, так почему бы не использовать эту же технику для отрисовки счётчика? Итак, вот структура для работы со спрайтами:


struct Sprite {
    Sprite(SDL_Renderer *renderer, const std::string filename, const int width) : width(width) {
        SDL_Surface *surface = SDL_LoadBMP((std::string(RESOURCES_DIR) + filename).c_str());
        if (!surface) {
            std::cerr << "Error in SDL_LoadBMP: " << SDL_GetError() << std::endl;
            return;
        }
        if (!(surface->w%width) && surface->w/width) { // image width must be a multiple of sprite width
            height  = surface->h;
            nframes = surface->w/width;
            texture = SDL_CreateTextureFromSurface(renderer, surface);
        } else
            std::cerr << "Incorrect sprite size" << std::endl;
        SDL_FreeSurface(surface);
    }

    SDL_Rect rect(const int idx) const { // choose the sprite number idx from the texture
        return { idx*width, 0, width, height };
    }

    ~Sprite() { // do not forget to free the memory!
        if (texture) SDL_DestroyTexture(texture);
    }

    SDL_Texture *texture = nullptr; // the image is to be stored here
    int width   = 0; // single sprite width (texture width = width * nframes)
    int height  = 0; // sprite height
    int nframes = 0; // number of frames in the animation sequence
};

В переменных состояния объекта у нас указатель на непосредственно текстуру, ширина одного спрайта, высота спрайта, и количество спрайтов в текстуре. Конструктор просто подтягивает .bmp файл и проверяет, что его размеры совпадают с ожидаемым. Ну а метод rect(idx) позволяет выбрать спрайт с индексом idx для последующей его отрисовке в зоне рендера.


А теперь давайте поговорим про счётчик. Я создал структуру под названием FPS_Counter, и просто вызываю её метод .draw() внутри основного цикла:


void main_loop(SDL_Renderer *renderer) {
    FPS_Counter fps_counter(renderer);
    while (1) { // main game loop
        [...]
        SDL_RenderClear(renderer); // re-draw the window
        fps_counter.draw();
        SDL_RenderPresent(renderer);
    }
}

Метод .draw() ведёт подсчёт вызовов, и отрисовывает счётчик, используя подгруженные спрайты с цифрами. Давайте внимательно посмотрим на эту структуру. Основая идея — измерять количество вызовов .draw() раз в некоторое время (у меня триста миллисекунд). Соответственно, у меня есть два инта — fps_prev хранит последнее измеренное значение fps, а fps_cur это текущий счётчик. Ещё нужно хранить временную метку timestamp для отслеживания этих самых трёхсот миллисекунд. Вот так выглядит полный код структуры:


struct FPS_Counter {
    FPS_Counter(SDL_Renderer *renderer) : renderer(renderer), numbers(renderer, "numbers.bmp", 24) {}

    void draw() {
        fps_cur++;
        double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();
        if (dt>=.3) { // every 300 ms update current FPS reading
            fps_prev = fps_cur/dt;
            fps_cur = 0;
            timestamp = Clock::now();
        }
        SDL_Rect dst = {4, 16, numbers.width, numbers.height}; // first character will be drawn here
        for (const char c : std::to_string(fps_prev)) { // extract individual digits of fps_prev
            SDL_Rect src = numbers.rect(c-'0'); // crude conversion of numeric characters to int: '7'-'0'=7
            SDL_RenderCopy(renderer, numbers.texture, &src, &dst); // draw current digit
            dst.x += numbers.width + 4; // draw characters left-to-right, +4 for letter spacing (TODO: add padding directly to the .bmp file)
        }
    }

    int fps_cur  = 0; // the FPS readings are updated once in a while; fps_cur is the number of draw() calls since the last reading
    int fps_prev = 0; // and here is the last fps reading
    TimeStamp timestamp = Clock::now(); // last time fps_prev was updated
    SDL_Renderer *renderer; // draw here
    const Sprite numbers;   // "font" file
};

fps_counter.draw(); inside main loop while(1) { ... }.


Вот тут можно посмотреть коммит с рабочим кодом.


Шаг третий: сорок тысяч fps это многовато, давайте поменьше


На данный момент у меня на ноуте вентиляторы крутятся так, что он порывается улететь. Давайте-ка снизим нагрузку на проц. Как заставить основной цикл исполняться не больше 50 раз в секунду? Самый наивный вариант — это что-то вроде такого кода:


    while (1) { // main game loop
        do_something();
        sleep(20);
    }
}

Мы можем тупо вставить задержку на 20 миллисекунд в тело цикла, получив максимум 50 fps. Такой подход имеет право на жизнь, но он предполагает, что время работы do_nothing() пренебрежимо. А если вдруг оно будет исполняться, скажем, за 12мс? Тогда нам задержку нужно не 20, а 8, иначе сильно проседает FSP. А ведь это ещё зависит от компа… Поэтому я предлагаю следующий подход:


    TimeStamp timestamp = Clock::now();
    while (1) { // main game loop
        double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();
        if (dt<.02) { // 50 FPS regulation
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
            continue;
        }
        timestamp = Clock::now();
        do_something();
    }
}

Мы просто храним временную метку timestamp, соответствующую последней отрисовке экрана, и не даём пройти внутрь цикла до тех пор, пока не истекут 20 миллисекунд. Задержка на 1мс вставлена для того, чтобы не грузить CPU на 100% пустыми проверками времени. Разумеется, в реальной игре за это время лучше делать что-нибудь полезное, считать физику, например.


Итак, вот результат:



Шаг четвёртый: отрисовываем уровень


Теперь давайте отрисуем карту уровня, вот тут соответствующий коммит. Я хочу видеть вот такой результат:



Для этого я сначала нарисовал текстуру 768 x 128 пикслей, в которую у меня упаковано шесть спрайтов каменюк размером 128x128:



Мой экран разбит на 192 клетки (16 по горизонтали и 12 по вертикали), и каждой клетке соответствует какая-то текстура.


Я создал структуру Map, которая используется следующим образом в основом цикле игры:


    Map map(renderer);
    while (1) { // main game loop
        [...]
        SDL_RenderClear(renderer); // re-draw the window
        map.draw();
        SDL_RenderPresent(renderer);
    }

Сама структура определена следующим образом:


struct Map {
    Map(SDL_Renderer *renderer) : renderer(renderer), textures(renderer, "ground.bmp", 128) {
        assert(sizeof(level) == w*h+1); // +1 for the null terminated string
        int window_w, window_h;
        if (!SDL_GetRendererOutputSize(renderer, &window_w, &window_h)) {
            tile_w = window_w/w;
            tile_h = window_h/h;
        } else
            std::cerr << "Failed to get renderer size: " << SDL_GetError() << std::endl;
    }

    void draw() { // draw the level in the renderer window
        for (int j=0; j<h; j++)
            for (int i=0; i<w; i++) {
                if (is_empty(i, j)) continue;
                SDL_Rect dst = { tile_w*i, tile_h*j, tile_w, tile_h };
                SDL_Rect src = textures.rect(get(i,j));
                SDL_RenderCopy(renderer, textures.texture, &src, &dst);
            }
    }

    int get(const int i, const int j) const { // retreive the cell, transform character to texture index
        assert(i>=0 && j>=0 && i<w && j<h);
        return level[i+j*w] - '0';
    }

    bool is_empty(const int i, const int j) const {
        assert(i>=0 && j>=0 && i<w && j<h);
        return level[i+j*w] == ' ';
    }

    SDL_Renderer *renderer; // draw here
    int tile_w = 0, tile_h = 0; // tile size in the renderer window

    const Sprite textures;         // textures to be drawn
    static constexpr int w = 16; // overall map dimensions, the array level[] has the length w*h+1 (+1 for the null character)
    static constexpr int h = 12; // space character for empty tiles, digits indicate the texture index to be used per tile
    static constexpr char level[w*h+1] = " 123451234012340"\
                                         "5              5"\
                                         "0              0"\
                                         "5          5   5"\
                                         "0          0   0"\
                                         "512340   12345 5"\
                                         "0              0"\
                                         "5             51"\
                                         "0     50      12"\
                                         "5          51234"\
                                         "0          12345"\
                                         "1234012345052500";
};

Самое главное тут — массив level, который определяет, какой клетке соответствует какая текстура. В методе .draw() я прохожу по всем клеткам уровня, и для каждой незанятой отрисовываю соответствующий спрайт. Вспомогательные методы is_empty(i, j) и get(i, j) позволяют определить, пуста ли клетка с индексами i, j, и понять номер спрайта. Ну а в конструкторе я просто подтягиваю соответствующий .bmp файл и определяю размер клетки в пикселях экрана.


Шаг пятый: персонаж и его анимация


Осталось совсем немного: непосредственно персонаж. Давайте для начала научимся показывать анимации. Я взял карандаш и нарисовал последовательность кадров, которая показывает идущего человечка:



Я хочу получить вот такой результат (коммит брать тут):



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


struct Animation : public Sprite {
    Animation(SDL_Renderer *renderer, const std::string filename, const int width, const double duration, const bool repeat) :
        Sprite(renderer, filename, width), duration(duration), repeat(repeat) {}

    bool animation_ended(const TimeStamp timestamp) const { // is the animation sequence still playing?
        double elapsed = std::chrono::duration<double>(Clock::now() - timestamp).count(); // seconds from timestamp to now
        return !repeat && elapsed >= duration;
    }

    int frame(const TimeStamp timestamp) const { // compute the frame number at current time for the the animation started at timestamp
        double elapsed = std::chrono::duration<double>(Clock::now() - timestamp).count(); // seconds from timestamp to now
        int idx = static_cast<int>(nframes*elapsed/duration);
        return repeat ? idx % nframes : std::min(idx, nframes-1);
    }

    SDL_Rect rect(const TimeStamp timestamp) const { // choose the right frame from the texture
        return { frame(timestamp)*width, 0, width, height };
    }

    const double duration = 1; // duration of the animation sequence in seconds
    const bool repeat = false; // should we repeat the animation?
};

Анимация не особо отличается от простых спрайтов, поэтому я её и унаследовал от структуры Sprite. У неё два дополнительных члена: время проигрывания анимации и булевская переменная, которая говорит, нужно ли играть анимацию в цикле. Конструктор просто наследуется от конструктора Sprite, а дополнительные методы позволяют узнать, закончилось ли проигрывание (animation_ended(timestamp)), и получить текущий кадр анимации (frame(timestamp) + rect(timestamp)).


Теперь осталось описать персонажа:


struct Player {
    enum States {
        REST=0, TAKEOFF=1, FLIGHT=2, LANDING=3, WALK=4, FALL=5
    };

    Player(SDL_Renderer *renderer) :
        renderer(renderer),
        sprites{Animation(renderer, "rest.bmp",    256, 1.0, true ),
                Animation(renderer, "takeoff.bmp", 256, 0.3, false),
                Animation(renderer, "flight.bmp",  256, 1.3, false),
                Animation(renderer, "landing.bmp", 256, 0.3, false),
                Animation(renderer, "walk.bmp",    256, 1.0, true ),
                Animation(renderer, "fall.bmp",    256, 1.0, true )} {
    }

    void draw() {
        SDL_Rect src = sprites[state].rect(timestamp);
        SDL_Rect dest = { int(x)-sprite_w/2, int(y)-sprite_h, sprite_w, sprite_h };
        SDL_RenderCopyEx(renderer, sprites[state].texture, &src, &dest, 0, nullptr, backwards ? SDL_FLIP_HORIZONTAL : SDL_FLIP_NONE);
    }

    double x = 150, y = 200; // coordinates of the player
    bool backwards = false;  // left or right

    int state = WALK;
    TimeStamp timestamp = Clock::now();

    const int sprite_w = 256; // size of the sprite on the screen
    const int sprite_h = 128;

    SDL_Renderer *renderer;   // draw here
    std::array<Animation,6> sprites; // sprite sequences to be drawn
};

Я сразу сделал заготовку под то, что у персонажа будет несколько характерных состояний (ходьба, прыжок, падение). Положение игрока на экране я задаю переменными x и y, они соответствют середине подошвы. Направление лево/право задаётся булевской переменной backwards, а переменная timestamp задаёт метку времени начала проигрывания анимации.


Использование этой структуры пока что идентично использованию карты и счётчика fps:


    Player player(renderer);
    while (1) { // main game loop
        [...]
        SDL_RenderClear(renderer); // re-draw the window
        [...]
        player.draw();
        SDL_RenderPresent(renderer);
    }

Шаг шестой: опрос клавиатуры и обработка столкновений


А теперь давайте научимся опрашивать клавиатуру и обрабатывать столкновения с картой (вот коммит). Я хочу получить вот такой результат:



Для обработки клавиатуры я добавил функцию handle_keyboard(), которая вызывает функцию смены состояния set_state() в зависимости от того, какие курсорные стрелки нажаты:


    void handle_keyboard() {
        const Uint8 *kbstate = SDL_GetKeyboardState(NULL);
        if (state==WALK && !kbstate[SDL_SCANCODE_RIGHT] && !kbstate[SDL_SCANCODE_LEFT])
            set_state(REST);
        if (state==REST && (kbstate[SDL_SCANCODE_LEFT] || kbstate[SDL_SCANCODE_RIGHT])) {
            backwards = kbstate[SDL_SCANCODE_LEFT];
            set_state(WALK);
        }
    }

    void set_state(int s) {
        timestamp = Clock::now();
        state = s;
        if (state==REST)
            vx = 0;
        if (state==WALK)
            vx = backwards ? -150 : 150;
    }

Для изменения положения на экране я вызываю функцию update_state, которая и занимается тем, что изменяет переменную состояния x:


    void update_state(const double dt, const Map &map) {
        x += dt*vx; // candidate coordinates prior to collision detection
        if (!map.is_empty(x/map.tile_w, y/map.tile_h)) { // horizontal collision detection
            int snap = std::round(x/map.tile_w)*map.tile_w; // snap the coorinate to the boundary of last free tile
            x = snap + (snap>x ? 1 : -1);              // be careful to snap to the left or to the right side of the free tile
            vx = 0; // stop
        }
    }

Для начала я считаю координату на следующем шаге: x = x + dt*vx, а затем проверяю, не попадает ли эта координата в заполненную клетку карты. Если такое случается, то я останавливаю персонажа, и обновляю x таким образом, чтобы она оказалась на границе заполненной клетки.


Шаг седьмой: сила тяжести!


Сила тяжести добавляется элементарно, мы выписываем абсолютно такое же поведение и для вертикальной координаты, лишь добавив ещё и увеличение вертикальной скорости vy += dt*300, где 300 — это ускорение свободного падения в 300 пикселей в секунду за секунду.


    void update_state(const double dt, const Map &map) {
        [...]
        y  += dt*vy;  // prior to collision detection
        vy += dt*300;   // gravity
        [...]
    }

Обработку столкновения по вертикали даже описывать не буду, она ничем не отличается от горизонтали, коммит брать тут, ну а вот и результат:



Последние штрихи


Единственное, что осталось добавить нашу заготовку — это обработку прыжков. Это делается заданием начальных скоростей vx и vy в зависимости от комбинации курсорных клавиш. Чисто по приколу у меня есть прыжки в высоту и прыжки в длину:



Заключение


Ну вот, собственно, и всё. Как я и обещал, игры как таковой у меня нет, но есть играбельная демка всего из 296 строк кода.


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


Have fun!

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


  1. fougasse
    19.09.2021 18:46
    -4

    1. А почему у вас в основном const T в аргументах функций? Это имеет какое-то преимущество для POD? Для std::string, вообще не понятен смысл данной манипуляции - уже или просто по значению, или по константной ссылке, если нет желания погружаться в move и т.п.

    2. Конструкции типа : for (int j=0; j<h; j++) for (int i=0; i<w; i++) {

      Очень error-prone, понятно, что экономится 1 строка, но стоит ли оно того?

    3. int(x) а не (int) x? Зачем?

    4. if (dt>=.3)?

      Статья, в принципе, интресная, но вот обучать людей надо начинать с нормального стиля и подхода, особенно, если плюсам, ИМХО.

    А так, полно мелочей, которые попахивают и портят впечатление от отличной задумки для первого проекта в учебном году.


    1. haqreu Автор
      19.09.2021 18:53
      +5

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

      Да и вообще, пулл реквест в студию :)

      Теперь по пунктам:

      1. Я привык ставить const в аргументах, чтобы только по заголовку было понятно, что есть вход, что есть выход. Конечно же, никаких особых преимуществ это для аргумента get(i, j) не даёт (кроме читабельности).

      2. Ой, ставить скобки или нет - это см. чувство прекрасного. Особой экономии строк я не добивался.

      3. А зачем писать (int)x? Почему не static_cast<int>(x)

      4. Не понял в чём проблема с ифом. Поясните?


      1. mvv
        19.09.2021 20:06

        А зачем писать (int)x?

        Вдруг понадобится заменить int на unsigned long long int, и всё, "функциональная" нотация не сработает, и "фсё пропало, клиент уезжает", придется ломать свою гордость и т.д.... :)


        1. haqreu Автор
          19.09.2021 20:11
          +5

          static_cast<unsigned long long int>(x) спасёт мир!


          1. mvv
            19.09.2021 20:49

            Так победим!


          1. tsilia
            20.09.2021 11:14

            uint64_t - и запись короче, и длина в битвах указана.


      1. fougasse
        19.09.2021 20:38
        -2

        Ну вот у вас то есть скобки, то нет.

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

        Как пример - CLion с питоновской «ошибкой» в плюсовом проекте.

        Про int - вы сами попробуйте объяснить почему изначально не cast (c-style или static), мне интересно какие преимущества оно даёт? Такой вопрос студенты не задают?


        1. haqreu Автор
          19.09.2021 20:46
          +4

          Насколько я знаю, static_cast<> в теории преимущества даёт, но в данном случае никаких. Что из трёх вариантов выбирать - это чисто вопрос стиля, и ваш си-каст ничем не выделяется.

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

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

          Ну и последнее, почему я должен писать свой код так, чтобы не нарываться на баги CLion? :)


      1. sashagil
        19.09.2021 21:59
        +5

        "Я привык ставить const в аргументах, чтобы только по заголовку было понятно, что есть вход, что есть выход. Конечно же, никаких особых преимуществ это для аргумента get(i, j) не даёт (кроме читабельности)."

        Отличная мотивация, а для меня - удобный момент снова разобрать элементы общих рекомендации по типам аргументов функций, заглянув в раздел Functions C++ Core Guidelines (и конкретно пересмотрев рекомендации F.15 и F.16).

        Начнём с того, что наиболее простой способ передачи данных в функцию и получения результатов - передача по значению (тип аргумента T) и получение всех выходов функции только в возвращаемом значении (результате) - который при множественности результатов становится композитным, например, пара, кортеж (tuple) или простая структура (лучший вариант, на мой взгляд, благодаря содержательным названиям полей). Современный C++ предоставляет удобную деконструкцию композитного результата в вызывающем контексте, нампример auto [ x, y, z ] = get_coordinates(a); (этот приём используется в фрагментах кода к C++ Core Guidelines I.10, F.21, ES.10, ES.11, ES.20).

        Недостаток: если тип T - достаточно крупного размера, копирование значения при передаче по значению может сказаться на производительности (что всегда не мешает проверить замерами). В этом случае помогает ссылка на константное значение, const T& - заметьте присутствие '&'. Только при наличии & появляется риск изменить внутри функции внешнее значение, и const помогает это предотвратить, а также просигналить о намереньях в сигнатуре функции: этот const T& параметр - входной, с той же подразумеваемой семантикой, как передача по значению (просто T), а ссылочность заведена для оптимизации производительности.

        Поставив const T (без '&') при передаче по значению вы не меняете семантику отношений вызывающего кода с функцией - и так и так параметр, передаваемый по значению, только входной (in). В этом случае const ограничивает набор действий с параметром в реализации функции (вы не сможете обновить его значение, это сходно константным локальным переменный вроде sprite_w и sprite_h в одном из фрагментов вашего кода. Лично я рассматриваю такую типизацию как неполезную - добавляет ненужный шум в сигнатуру, накладывает ограничение на реализующий функцию код (иногда приводящее к более длинной и менее читабельной форме этого кода).

        При передаче по ссылке ('&') спецификатор const действительно важен! Без него функция имеет право поменять значение переданной переменной в вызывающем контексте, таким образом параметр становится входным/выходным (in-out, описанный в рекомендации F.17) -- чего лично я стараюсь избегать, предпочитая все выходы функции возвращать в результате.

        Кроме этого усложнения передача по ссылке может привнести подводные камни, так что прибегать к ней имеет смысл только убедившись, что копирование крупного значения T действительно влияет на производительность. Здесь я имею в виду возможные проблемы с aliasing. При получинии параметра const T& код функции может резонно положиться на соглашение, что данный параметр - входной, его значение в момент вызова функции таковым и остаётся до момента выхода из функции. Однако, если функция обращается к другим функциям, имеющим (другим путём) доступ к той же переменной в вызывающем контексте, это значение неожиданно может поменяться. С этой проблемой можно бороться, предотвращая aliasing с помощью &&, но, увы, такой подход усложняет код.


        1. haqreu Автор
          19.09.2021 22:20
          +1

          Комментарий длинный и полезный, но в рамках текущей беседы малоприменимый. Чем мешает конкретно в моём случае объявление const? Доведём до абсурда: нужно ли переменную-член width класса спрайт обозначать как const?

          Касательно structural binding auto [x,y,z] = foo(), это фича C++17, что, конечно, хорошо, но есть два тонких момента:

          1. Не везде есть компилятор, поддерживащий этот стандарт (да, я знаю, что на дворе 2021й год, и что конкретно в данном проекте у меня зачем-то c++17 прописан в cmakelists :) )

          2. Мы сильно полагаемся на то, что оптимизатор компилятора уберёт все эти tuple, и запихнёт возвращаемое значение в регистры какие-нибудь. Чем это лучше усложения сигнатуры при добавлении constвходному параметру? (при этом я не уверен, что сигнатура усложняется, и оптимизатор уж точно любит const и особенно constexpr)

          Я достаточно наработался с кодом, где объект передаётся по значению, а внутри там спрятан какой-нибудь хитрый shared_ptr,поэтому у меня просто рефлекс ставить const на то, что изменяться не должно. Да и вообще не вижу смысла писать код, который изменяет значение целочисленного аргумента, переданного по значению. Это точно ухудшение читабельности...

          Жду претензий к названиям переменных :)


          1. sashagil
            20.09.2021 06:42

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

            Моё отношение к coding guidelines довольно прагматично. Я открыт к кодификации в coding guidelines сложившегося по тем или иным причинам стиля, но мне важна внутренняя непротиворечивость такого стиля. Обсуждая с коллегами аспекты вроде рассматриваемого здесь (про желательность / нежелательность спецификатора const при объявлении локальных переменных: параметры -- локальные переменные в реализации функции) я выясняю их мотивы и подталкиваю к рефлексии на тему, насколько логичны их привычки? В случае с вашей привычкой некая поименованная величина в теле функции может оказаться констатной только из-за того, что она -- параметр, а не локальная переменная. Вы не находите сочетание строгости в отношении одних локальных переменных (параметров функции) и расслабленность в отношении других локальных переменных (не-параметров) нелогичным?

            Я несколько лет назад задал самому себе такой вопрос и решил, что у меня, как у сторонника логичных самосогласованных практик, два варианта -- писать const по возможности везде (в том числе при объявлении локальных переменных и да, членов классов -- во имя самосогласованности данного правила) или писать const только там, где этот спецификатор реально детализирует контракт (как в типах параметров const T&). Порефлексировал и принял выбор в пользу второго варианта. Подтверждение нахожу в стандартной библиотеке и других пакетах, коде коллег -- поэтому удивился, увидев типа параметров const T у вас (для меня это -- ухудшение читабельности, глаз запнулся), после чего напечатал предыдущий длинный комментарий.

            Упомянутый вами случай с shared_ptr, конечно, прискорбен (я, безусловно, предпочитаю unique_ptr и проработку дизайна компонент так, чтобы необходимости использования shared_ptr не было). Опять же, порефлексировав некоторое время назад, я пришёл к практике инкапсуляции деталей реализации сколь-либо сложного класса в pimpl (последнее время использую zero-cost "fast pimpl" после того, как написал поддерживающий fast impl темплейтный код, до этого использовал pimpl на unique_ptr) и запрете (= delete;) у него copy costructor, copy assignment operator. Тогда передача по значению такого сколь-нибудь сложного класса оказывается намеренно запрещена (чтобы не платить за копирование), для использования класса в функции передаётся const T& (отлично) или T& (что сигналит в сигнатуре-контракте о мутациях параметра, выполняемых в функции, о коде становится сложнее рассуждать -- цена за предполагаемую или доказанную замерами оптимизацию производительности... лучше бы доказанную). Просто T -- передача по значению (отличный выбор для лёгких "скалярных" типов, небольших struct-ов, POD). Добавление в этом случае const T -- ненужный шум в контракте и нелогичное ограничение на стороне реализации функции (нелогичное - при использовании достаточно широко принятой практике не вставлять const в объявление почти каждой локальной переменной, это и ваша практика тоже).

            Возвращаясь к coding guidelines. С другой стороны, я не хочу фиксировать в coding guidelines чьи-то (мои в том числе) идеосинкразии, обусловленные индивидуальным профессиональным опытом (как ваша печальная история с shared_ptr внутри переданного по значению параметра). Вместо этого я пытаюсь отслеживать тренды в развитии языка, пытаюсь делать coding guidelines, к которым прикладываю руки, как можно более future-proof. С++ Core Guidelines -- хороший источник информации на этот счёт, на мой взгляд. Движение в сторону более частого использования практик функционального программирования (пример из Core Guidelines: "F.8: Prefer pure functions") прослеживается и там, и в современных UI библиотеках, и в практиках безопасного многопоточного программирования, поэтому я стараюсь сам и подталкиваю коллег использовать типы параметров T и const T& - избегая T& (предпочитая стиль чистых функций с немутирующими аргументами и возвратом всего через результат -- пока выигрыш в производительности от in-out стиля с T& не доказан замерами).

            Вдогонку добавлю (заглянув в другую ветку комментариев) про преимущество static_cast. Для меня очевидное преимущество -- в том, что такая практика согласована с C++ Core Guidelines, конкретно -- с "ES.49: If you must use a cast, use a named cast".


            1. haqreu Автор
              20.09.2021 10:08

              Вы не находите сочетание строгости в отношении одних локальных переменных (параметров функции) и расслабленность в отношении других локальных переменных (не-параметров) нелогичным?

              А я, кстати, зачастую следую Con. 4 (Use const to define objects with values that do not change after construction), и объявляю неизменяемыми и локальные переменные. Тут местами поленился, да.


              1. sashagil
                20.09.2021 21:53

                У меня ровно сейчас, к сожалению, нет времени проверить легкодоступные статистические анализаторы, но, кажется, проверка на отсутствие модификации локальной переменной (если нет модификаций - диагностика "добавьте const!") может существовать, она технически нетрудна. Обращу на это внимание между делом - но в моём текущем проекте всё-таки практика применять const только в public APIs. Спасибо за дискуссию! По существу - продолжайте, пожалуйста, такие публикации, полезное для сообщества дело!


          1. Aldrog
            21.09.2021 18:33

            Я достаточно наработался с кодом, где объект передаётся по значению, а внутри там спрятан какой-нибудь хитрый shared_ptr

            А как от этого поможет добавление к параметру const? Реализация функции точно так же может сколько угодно инкрементировать счётчик или пройти по этому указателю и что-то изменить.


            и оптимизатор уж точно любит const

            В случае с параметром семантика никак не меняется и компилятору дополнительных возможностей для оптимизации не открывается. По крайней мере в теории.


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

            В некоторых (пусть и нечастых) случаях может и улучшить.
            Например, вся функция — это цикл со счётчиком, начинающимся с параметра. Особенно если for по семантике не очень подходит. Тогда константность параметра заставит начинать функцию с auto value = param;, что читаемость ну никак не улучшит.


            1. haqreu Автор
              21.09.2021 19:59

              А как от этого поможет добавление к параметру const?

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

              В случае простого POD аргумента constне даёт ничего, кроме чувства удовлетворения :)


        1. kotlomoy
          21.11.2021 15:11
          +3

          А я со временем пришел к выводу, что const нужно ставить везде, где только можно. Что по умолчанию переменные должны быть константами. Что программист должен явно декларировать свое намерение изменять значение переменной после ее объявления. Я могу ошибаться, но в Rust вроде бы так и сделано, есть модификатор mut. Тоже касается методов класса, по умолчанию они должны быть const. И т.д. Помогало ли это отловить ошибки в реальном уже? Да, помогало, когда в результате копипасты значение присваивалось не той переменной, что задумывалось.


          1. sashagil
            22.11.2021 00:46

            Отличная практика в проекте, в котором вы владеете coding guidelines и можете в одночасье расставить везде const, да ещё и поддержать практику статической проверкой (такие инструменты есть). В Rust то, что неизменяемость идёт по дефолту (ничего не надо писать, наоборот, надо писать mut для изменяемости) - замечательно. А в C++ дефолт, увы, противоположный. Согласен с спецификацией const для не-мутирующих функций-членов, но вот, const для каждой локальной переменной (и параметров, переданных по значению) - редко встречаю такое, так что ваш тезис мне понятен, но сам на практике предпочёл в данный период не применять (пока - из-за отсутствия технической возможности поддержать статическим анализом). Когда под рукой будет на готове опция статической проверки (что все немутирующие переменные и методы помечены const) - обсужу с коллегами и, возможно, начнём внедрять. В соло (хобби-)проектах - тоже за! Но, в коллективном проекте с большим объёмом кода без соблюдения этого правила и без возможности поддержки правила статическим анализом - взвесив за и против, решил пока так. Первый приоритет для меня - получить возможность обеспечить такую проверку статическим анализом (до этого - нет смысла и обсуждать с коллегами!), потом - достичь консенсуса с коллегами, что они согласны - эта новая строгость пойдёт на пользу в перспективе, а потом уже - внедрять. Внедрять на существующем коде, где такого правила нет, без поддержки автоматической проверкой - гиблый номер.


            1. Aldrog
              22.11.2021 13:15

              Насчёт переменных не уверен, но по крайней мере для методов в clang-tidy есть readability-make-member-function-const.


    1. besworland
      20.09.2021 12:26
      +1

      Не совсем понимаю откуда столько минусов? Вопрос задан абсолютно по существу. При прочтении статьи у меня были аналогичные вопросы. Если ещё этот проект используется в учебных целях, то аргумент автора «про измеритель прекрасного» заучит более чем странно. Существует множество описаний хороших практик при написании кода на плюсах и именно на таких примерах стоит и рассказывать и показывать их. Ещё мешанина из С и С++ стилей прям очень сильно бросается в глаза.

      В общем, вам плюс за замечания:)


      1. haqreu Автор
        20.09.2021 12:30

        Минусы обычно ставят не за вопросы по существу, а за грубость. Ну и надо ещё хорошо понимать, что:

        1. ваше чувство прекрасного и моё могут не совпадать

        2. если хотеть всегда только идеальный код (это невозможно), то можно ни одного проекта не сделать.

        И вообще, давайте меряться гитхабами :)


        1. besworland
          20.09.2021 12:45

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

          Вам по пунктам предложили варианты, как можно сделать немного лучше. Это же возможность что-то новое узнать и улучшить свои навыки. Или вы на каждое ревью так реагируете? Про идеальный код никто и не говорит, но есть набор правил, следовать которым считается хорошим тоном и появились они не просто так. Гляньте просто на досуге хотя бы тут.


          1. haqreu Автор
            20.09.2021 13:16

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

            А я по пунктам ответил, что лучше не получится. И что дальше? Пока что единственное приличное предложение - это чуть переоформить вызов chrono, да и это предложение пока не оформилось во что-то окончательное.


        1. Tujh
          20.09.2021 13:04

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

          Минусы обычно ставят не за вопросы по существу, а за грубость


          1. haqreu Автор
            20.09.2021 13:13

            Гхм. Да я не собирался авторитетом давить, что ж вы все такие нервные. Я говорю о том, что я делаю проекты, и выкладываю в общий доступ. И даже неидеальные, они находят свою публику. И да, мне не стыдно выкладывать код как есть. Я в своей жизни кода читаю много, и точно знаю, что мой не самый хороший, но и не самый плохой. Зато он есть и доступен.

            Кстати, ни одного пулл реквеста с хабра так и не приехало пока. Только фразы о том, что у меня код плохо пахнет и я глумлюсь надо хроно. Так держать :)


            1. haqreu Автор
              22.09.2021 15:49

              Поправка: @besworland прислал первый пулл реквест, большое ему спасибо, now we are talking!


          1. haqreu Автор
            21.09.2021 17:00

            Кстати, вдогонку: вот вы утверждаете, что я давлю авторитетом вместо того, чтобы отвечать на вопросы.

            Подскажите, пожалуйста, на какой вопрос я не ответил? У меня сложилось впечатление, что отвечал я сразу же и по пунктам :)


  1. ncr
    20.09.2021 03:14
    +10

    Не хотелось писать про код, особенно после того, как по нему потоптались другие комментаторы, но…

    double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();
    if (dt<.02)

    Зачем так глумиться над chrono? Это хорошая библиотека, она такого не заслуживает.

    if (Clock::now() - timestamp < 20ms)


    1. haqreu Автор
      20.09.2021 03:20
      -6

      Тут смешно в том, что все комментарии с критикой были мимо кассы, ни один не улучшал моего кода (который, ещё раз, не претендует на идеальность).

      А теперь ближе посмотрим на предложенный вами кусочек кода.

      if (Clock::now() - timestamp < 20ms) 

      Ой, а 20ms прям-таки скомпилируется? Ладно, давайте представим, что 20ms это опечатка, вы хотели 20. Что я делаю не так? Вот этот код не компилируется.

      #include <chrono>
      #include <thread>
      #include <iostream>
      
      using Clock = std::chrono::high_resolution_clock;
      using TimeStamp = std::chrono::time_point<Clock>;
      
      int main() {
          TimeStamp timestamp = Clock::now();
          std::this_thread::sleep_for(std::chrono::milliseconds(10));
          if (Clock::now() - timestamp < 20)
              std::cerr << "foo" << std::endl;
          return 0;
      }

      Ну а про интегратор мы поговорим потом.


      1. ncr
        20.09.2021 03:55
        +7

        Ой, а 20ms прям-таки скомпилируется?
        Да.


        1. haqreu Автор
          20.09.2021 04:23

          О, я узнал новую для себя штуку (chrono_literals), за это спасибо, я люблю учиться. А теперь мне нужно получить длительность dtпосле этого условия. Вы предлагаете вот такой код? Или что-то более читаемое?

              if (sc::now() - timestamp < 10ms)
                  std::cerr << "foo" << std::endl;
              double dt = (sc::now() - timestamp).count()/1e6;
          


          1. ncr
            20.09.2021 04:49
            +4

            О, я узнал новую для себя штуку (chrono_literals)
            Суть не в литералах, они просто для удобства — с тем же успехом можно написать milliseconds(20). Суть в том, что chrono предлагает type safety и полный набор операций над временем. Rule of thumb: если хочется написать .count(), то, наверное, вы что-то делаете не так.

            Или что-то более читаемое?
            (now() - timestamp) / 1.0s


            1. haqreu Автор
              20.09.2021 09:28

              Итак, в сухом остатке. Вы предлагаете вот эту строчку

              double dt = std::chrono::duration<double>(Clock::now() - timestamp).count();

              заменить на

              double dt = (Clock::now() - timestamp)/std::literals::chrono_literals::1.0s;

              И вот тут во мне начинает агонировать физик. Секунды делить на секунды - это безразмерная величина, а вовсе не секунды. Почему и как вторая запись должна быть безопаснее (ведь претензия только к этому?) первой?


              1. Tujh
                20.09.2021 10:50

                using std::literals::chrono_literals;

                в начале срр-файла спасает очень многих.

                Секунды делить на секунды

                chrono::duration хранит время не в секундах же, а в попугаях, заданных на этапе компиляции.

                The only data stored in a duration is a tick count of type Rep

                Так что эта строка как раз и переводит из "попугаев" в секунды.

                the tick period is a compile-time rational fraction representing the time in seconds from one tick to the next.


                1. haqreu Автор
                  20.09.2021 11:18
                  -4

                  Да пофиг на попугаи/секунды, now()-ts имеет тип duration, равно как и 1.0s. Мне кажется диким одно делить на другое, и говорить, что так и надо. Величина получается безразмерная. Короче, чем моя строчка с непосредственным кастом в секунды хуже этого странного деления?


                  1. DistortNeo
                    20.09.2021 19:25
                    +1

                    Да пофиг на попугаи/секунды, now()-ts имеет тип duration

                    Конкретно в данном случае оно имеет тип std::chrono::high_resolution_clock::duration, который является std::chrono::duration<rep, period>, а какие там у него rep и period — а хрен это знает, это определяется реализацией.


                1. DistortNeo
                  20.09.2021 19:05

                  chrono::duration хранит время не в секундах же, а в попугаях, заданных на этапе компиляции.

                  Таки вы не правы:
                  https://en.cppreference.com/w/cpp/chrono/duration


                  В случае std::duration<T> вторым шаблонным параметром будет std::ratio<1>, а значит, внутренним представлением будут секунды.


                  Но вот в каком формате нам придёт ***clock::now(), стандарт не определяет.


              1. domix32
                20.09.2021 10:50
                -3

                Да не нужны там дикие неймспейсы. Можно сразу писать 10ms


                1. haqreu Автор
                  20.09.2021 11:20
                  -2

                  А как он найдёт 10ms без указания неймспейса? Нужно либо явное указание std::literals::chrono_literals::10ms или using std::literals::chrono_literals. При этом второй вариант вполне может быть нежелательным.


                  1. domix32
                    20.09.2021 21:54

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


              1. ncr
                20.09.2021 12:27
                +3

                Секунды делить на секунды — это безразмерная величина, а вовсе не секунды
                Не секунды на секунды, а длительность интервала в неких внутренних значениях на длительность одной секунды в них же. Получая в результате да, безразмерное количество секунд, но вы же этого и хотите — double по определению не имеет размерности.

                как вторая запись должна быть безопаснее первой?
                Вторая запись явно говорит читателю, что автор кода хочет получить количество секунд. В первой же берется .count() каких-то внутренних попугаев и надо помнить (или идти читать), как устроен duration, какие там параметры шаблона и какой период по умолчанию.


                1. haqreu Автор
                  20.09.2021 13:28
                  +1

                  Ну вот смотрите, я новичок в использовании chrono. Когда я вижу запись duration/1.0s, мне совершенно неочевидно, что это приведение интервала к секундам. Никогда и нигде в моей жизни такого не было. В то же время как явный каст из моего кода сразу подсвечивается в IDE, и там всё чётко видно.


                  1. ncr
                    20.09.2021 15:45

                    sizeof(array) / sizeof(array[0]), наверное, встречали хоть раз и диссонанса от деления размера на размер не возникало?
                    Концептуально это то же самое.


                    1. haqreu Автор
                      20.09.2021 17:11

                      Не, тут диссонанса нет никакого. Количество байт/массив поделённое на количество байт/элемент это размерность колво элементов / в массиве.

                      Ровно так же у меня не возникает никаких вопросов к делению на CLOCKS_PER_SEC из time.h. А вот к делению на одну секунду... Очень неудачный выбор имени, как мне кажется.


                      1. DistortNeo
                        20.09.2021 17:17
                        +3

                        А я вообще не понимаю, зачем делить. Есть вполне удобная альтернатива в виде std::chrono::duration_cast. Да, она очень многословная, но читаемость с ней намного лучше.


                      1. haqreu Автор
                        20.09.2021 17:20

                        О, так ведь об этом и разговор. Я использовал прямой каст, а меня за это ругают :)


                      1. Tujh
                        20.09.2021 17:20

                        А вот к делению на одну секунду

                        К делению величины <попугаев в 1 секунду>, а не секунду.

                        Это проект для себя или для студентов?


                      1. haqreu Автор
                        20.09.2021 17:25
                        +1

                        В записи CLOCKS_PER_SEC я чётко вижу попугаи в секунду, в записи 1.0s не вижу. Кажется ли вам это удачным выбором? Вы от моего кода-то отвлекитесь на секундочку, мы сейчас про STL говорим, и о том, почему вдруг прямой каст в секунды, рекомендуемый на том же cppreference, вдруг стал хуже деления на какой-то литерал с неудачным именем.


                      1. Tujh
                        20.09.2021 17:41

                        В записи CLOCKS_PER_SEC я чётко вижу попугаи в секунду, в записи 1.0s не вижу

                        потому что не понимаете как устроен std::chrono, при этом упорно всем доказываете, что понимаете, что происходит и ругаете, что другие "делят секунды на секунды". Вообще интересно как вы с такой позиции смотрите на "кванты процессорного времени" :)


                      1. haqreu Автор
                        20.09.2021 17:48
                        +1

                        Ну расслабьтесь вы, я понял, что вы меня не любите. Вы лучше по делу ответьте, а не на личности переходите. Напоминаю вопрос: вся эта ветка сводится к тому, что вместо вот этой моей строчки

                        double dt = duration<double>(Clock::now() - timestamp).count();

                        настойчиво предлагается использовать вот такую:

                        double dt = (Clock::now() - timestamp)/1.0s;

                        Ни одного разумного аргумента при этом я не услышал (ну, помимо того, что ".count() это нельзя-нельзя" - почему???)

                        Мои аргументы: первая строчка интуитивно понятнее, и мой IDE мне выдаёт конкретно этот конструктор, и говорит, в каких единицах выдаётся результат. Со второй же строчкой единицы нужно угадывать.

                        А теперь ответьте, пожалуйста, по существу: почему нужно использовать именно вторую запись, и для чего вообще придуман duration_cast?


                      1. Tujh
                        20.09.2021 18:17
                        -1

                        для чего вообще придуман duration_cast

                        Что бы в time_point можно было поместить что угодно, хоть int, хоть double (если говорить упрощённо) и не было нужды писать

                        double dt = ...

                        а по итогу можно было в месте использования взять тот самый count()

                        auto dt = std::chrono::duration_cast<
                                std::chrono:milliseconds
                            >( sc.now() - timestamp ).count()
                        if( dt > 200 ) {
                            ...

                         читая этот код, я вожу, что тут речь идёт о 200 миллисекндах, а читая

                        double dt = duration<double>(
                            Clock::now() - timestamp
                        ).count();
                        if( dt > 0.02 ) {
                            ...

                        константа 0.02 требует пояснений


                      1. ncr
                        20.09.2021 18:39

                        ".count() это нельзя-нельзя" — почему???
                        Потому что count — это не секунды, это тики, которые в каждом duration свои.
                        То, что в duration<double>они совпадают с секундами — частный случай, который из типа этого duration не слишком очевиден.

                        Это, однако, совсем не означает, что так делать нельзя. Кастите на здоровье. Мой изначальный комментарий был о сравнении c фиксированной величиной, для чего переводить все в даблы не было никакой необходимости.

                        для чего вообще придуман duration_cast?
                        — Для преобразований с потерей информации (1337ms -> 1s)
                        — Для шаблонов.


                      1. haqreu Автор
                        20.09.2021 19:14

                        О, наконец-то у нас забрезжила надежда взаимопонимания. Итак, я действительно согласен с тем, что явное указание ms в сравнении if (now() - ts < 20ms) это нагляднее, нежели if (dt<0.020). Спасибо за указание существования литералов, это симпатично.

                        Другой вопрос, что double dt мне нужен по-любому для интеграторов типа x += vx * dt, а оттуда уже не является очевидным, насколько нужно вычислять этот временной интервал дважды, один раз в ифе, а второй раз для интегратора.

                        В сухом остатке: я так и не понял, где я так глумился над славной библиотекой chrono, и нужно ли вообще править мой код.


                      1. Aldrog
                        21.09.2021 18:08

                        А в чём проблема сохранить интервал в переменную типа duration?


                        const auto dt = Clock::now() - timestamp;

                        А приводить к double тем или иным способом уже только по необходимости.


                      1. haqreu Автор
                        21.09.2021 18:16
                        +1

                        А я так и сделал. Надо только в паре других мест заменить дубли на хроно.


  1. anonymous
    00.00.0000 00:00


  1. Aetet
    20.09.2021 08:20
    -1

    Большое спасибо @haqreu.

    Всегда с удовольствием читаю ваши статьи!

    Пишите еще!


  1. Tujh
    20.09.2021 10:39
    +3

    Небольшой момент, который хотелось бы уточнить, header guards специально проигнорированы или случайно?

    #pragma once

    занимает одну строку, поддерживается всеми современными компиляторами, если уж не хочется использовать классические #ifndef/#define/#endif


    1. haqreu Автор
      20.09.2021 11:21
      -5

      А зачем ставить header guards там, где они не нужны? Конкретно в этом проекте они не нужны. Ну и #pragma once нестандартный, я предпочитаю

      #define ...

      #ifdef ...

      ...
      #endif


      1. DistortNeo
        20.09.2021 11:44
        +2

        `#pragma once` уже давно стал стандартом де факто. Глупо писать по-старинке.


        1. haqreu Автор
          20.09.2021 12:00

          Хм. Ну тут я только могу сказать, что глупо делать утверждения, не подкреплённые аргументацией :)

          Да и вообще, надо на модули переходить.


          1. DistortNeo
            20.09.2021 12:38
            +5

            Вам какая аргументация по какому из моментов нужна?

            1. h-файл без include guard (в любом его варианте) — это плохая практика, потому что код вы пишете не только для себя, но и для других. Где гарантии, что по мере разрастания кода не получится ситуация, что файл будет включён более одного раза со всеми вытекающими эффектами?

            2. `#pragma once` vs `#ifdef` — типа, на самом деле, холиварная. Но первый вариант я считаю более удобным, т.к. меньше риск допустить ошибку. Ну а про поддержку `#pragma once` всеми современными компиляторами вы и сами можете погуглить.

            3. Модули в данном случае — это оверкилл.


            1. haqreu Автор
              20.09.2021 13:22

              1. В этом проекте меня нет заголовочных файлов как таковых, я просто цпп код раскидал по разным файлам.

              2. То есть, вы лично считаете прагму удобнее, но при этом называете глупостью действия тех, кто работает согласно стандарту, да?

              3. Да, оверкилл. Так же, как было бы оверкиллом делать .h+.cpp на каждую структуру, у меня описанную. Они просто не нужны на проекте такого размера.


              1. DistortNeo
                20.09.2021 14:40

                Тогда просто не давайте им расширение `.h`, не вводите других в заблуждение.
                В целом же всё это костыльные решения для преодоления недостатка C/C++: 1 .cpp файл = 1 TU.


    1. DistortNeo
      20.09.2021 11:40

      Так у автора это и не заголовочные файлы, а просто включение фрагментов кода в cpp-файл.


      1. Tujh
        20.09.2021 12:53

        просто включения должны иметь расширение .inl, а не .h


        1. haqreu Автор
          20.09.2021 13:34

          Хм. А STL файлы зачастую вообще расширения не имеют. Раз уж на то пошло, то скорее .inc, т.к. .inl скорее для инлайненных функций, чтобы они были доступны во всех модулях. Но это уже настолько нестандартные договорённости, что даже странно такое критиковать.


  1. Sergey_Kovalenko
    20.09.2021 12:07

    Супер. А можно то же самое для людей из смежных областей, для которых программирование - не профессия я полезный инструмент, и словосочетание Cmake звучит как что-то очень таинственное. Вот скажем я - умею создавать (довольно объемные по 5-10 тысяч строк кода) консольные приложения в VisualStudio и нажимать кнопку RUN в дебаг-режиме. Есть ли способ с такими знаниями работать с окошками и графикой на рабочем столе, а не в черно-белом текстовом терминале? Желательно чтобы этот способ был не сложнее проставления флажков при создании проекта и нажатии каких-то кнопов в самой VS. Буду благодарен за дельный совет.


    1. haqreu Автор
      20.09.2021 12:21
      +1

      Ну собственно, поставьте утилиту cmake, натравите её на мой репозиторий, и она вам сгенерирует проект .sln под visual studio, а дальше вы уже умеете. Cmake это такая фиговина, которая под разные ОС/компиляторы делает сборочные файлы. В вашем случае под вижл студию.

      Чтобы не париться с гитом, скачайте просто репозиторий как .zip, распакуйте его, запустите cmake, укажите ему папку с проектом, и папку, в которую он будет собирать бинарники. Затем нажмите кнопочку "сконфигурировать", а затем "сгенерировать", и у вас появится .sln.


      1. Sergey_Kovalenko
        20.09.2021 12:47
        +2

        Примерно также автослесарь объясняет блондинке по телефону, что та должна покрутить в своем автомобиле, чтобы тот поехал. Для автослесаря, конечно, все просто, но вот для блондинки ...


      1. Tujh
        20.09.2021 12:58
        +2

        Вы готовы объяснить человеку, не разбирающемуся в С/С++ почему в сгенерированном .sln содержатся "непонятные" цели ALL_BUILD и ZERO_CHECK?

        Не проще ли тогда просто попросить открыть CMakeLists.txt непосредственно в студии (в VSCode тоже работает), так как студия уже давно умеет обрабатывать CMake скрипты без этапа "генерация sln"?

        https://docs.microsoft.com/en-us/cpp/build/cmake-projects-in-visual-studio


  1. anonymous
    00.00.0000 00:00


  1. EduardGuschin
    21.09.2021 08:12

    Коллеги, давно ломаю голову над одним вопросом. SDL2 поддерживает отрисовку спрайтов по координатам с плавающей запятой, но я никак не могу избавиться от бага плавающих пикселей в моем пет-движке.

    Как он выглядит, можно посмотреть тут: https://www.youtube.com/watch?v=CYhqpEiUcdE (да, это не артефакты кодирования видео, это реальный эффект при работе приложения).

    Если кто нибудь подскажет, в какую сторону копать, буду очень благодарен.


    1. haqreu Автор
      21.09.2021 10:06

      Трудно сказать, нужно смотреть код. Возможно, фреймрейт плавает, а возможно, есть баг с округлением чего-то.


      1. EduardGuschin
        22.09.2021 06:51

        Не совсем понял, что вы подразумеваете под плавающим фреймрейтом.


        1. haqreu Автор
          22.09.2021 08:47

          Я имел в виду, насколько ровно у вас идут перерисовки экрана по времени.


    1. lgorSL
      21.09.2021 12:39

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


      1. EduardGuschin
        22.09.2021 06:45

        Да, это первое, что приходит в голову. Но я, все перепроверив с десяток раз, в упор не вижу, в каком месте косяк.


        1. haqreu Автор
          22.09.2021 08:48
          +1

          А возьмите один "дрожащий" спрайт, и для каждой перерисовки выведите в файл метку времени с точностью до миллисекунды и его текущую координату, которую вы отправляете в SDL. А потом в экселе просто постройте график по этому .csv файлу. Насколько он гладкий? Просто ли там ступеньки? Если да, то насколько большие? А он точно монотонный?


          1. EduardGuschin
            22.09.2021 13:13

            Взял спрайт дома. Взял участок данных за промежуток времени, в который происходит самая заметная "рябь" (209 строк с 29с:67мс по 31с:12мс). График получился не ступенчатый и не совсем ровный. Именно эти координаты передаются в функцию SDL_RenderCopyExF.

            График и данные


            1. haqreu Автор
              22.09.2021 13:29

              Красиво, спасибо. А время как интерпретируется? Почему есть дубликаты меток времени?


              1. EduardGuschin
                22.09.2021 14:01

                Просто отрисовка происходит чаще

                "59:21:6927","1780,8"

                "59:21:6987","1780,8"

                "59:21:7047","1780,8"

                "59:21:7108","1780,8"

                "59:21:7173","1780,8"


                1. haqreu Автор
                  22.09.2021 14:12

                  Подождите, 59 - это минуты, 21 - секунды, а ещё четыре цифры потом? Десятитысячные секунды? То есть, отрисовка раз в 60 миллисекунд?


                  1. SGrek
                    22.09.2021 14:18

                    Или 6 мс?!


                    1. haqreu Автор
                      22.09.2021 14:20

                      А, ну да, я считать разучился. 60 десятитысячных, если это десятитысячные. Но тогда вопрос @EduardGuschin: если смотреть на список, который вы привели под графиком (давайте возьмём конец):

                      53:30:99

                      53:30:99 <- перерисовка меньше чем за одну сотую секунды

                      53:31:00 <- перерисовка меньше чем за одну сотую секунды

                      53:31:00 <- перерисовка меньше чем за одну сотую секунды

                      51:31:12 <- сто двадцать миллисекунд при цели в шесть??

                      А можете теперь построить ещё второй график (прямо по тому же csv, что у вас есть), где будет в миллисекундах время между двумя отрисовками спрайта? По оси абсцисс номер отрисовки спрайта, по оси ординат время между отрисовками.


                      1. EduardGuschin
                        22.09.2021 15:50

                        Вертикальная синхронизация отключена, верхний лок FPS - 144, просто чтобы впустую не гонять GPU, поэтому время между кадрами может быть очень маленьким. Попробовал сделать лок FPS на 15, наблюдается та же проблема с рябью.

                        Для вычисления времени между двумя кадрами я, по аналогии с Unity, сделал свойство DeltaTime, которое можно получить в любой момент времени. Его удобно юзать, чтобы открепить скорость воспроизведения анимации от FPS.

                        Для этого графика пришлось взять другие данные, так как в старых метка времени в мс была не больше двух знаков.

                        Получилось как-то так:


                      1. haqreu Автор
                        22.09.2021 16:09

                        В целом график относительно приличный, но вопрос со 120мс из предыдущего скриншота остался. Но как бы то ни было, fps чуть-чуть да плавает. Используете ли вы эти настоящие значения для обновления координаты спрайта, или она вычисляется при помощи константы какой-нибудь?


                      1. EduardGuschin
                        22.09.2021 17:03

                        Только сейчас дошло, о чем речь с 120мс. Вообще при рендере таких заметных просадок по FPS нет, скорее всего так получилось, потому что участок с рябью я ловлю выводом в консоль и закрытием окна (чтобы не терять время на запись в файл). В момент, когда ЛКМ зажимает кнопку закрытия окна отрисовка останавливается. В общем, скорее всего 120мс - это скорость моего клика.

                        По поводу значений - да. Я сделал функцию, которая заставляет камеру плавно следовать за персонажем. Там для указания скорости слежения используется DeltaTime, чтобы она не зависела от FPS.

                        var camera_pos =  Math.Lerp(Camera.Transform.Position.X, _playerAnimator.Transform.Position.X, 1 * Time.DeltaTime);
                        Camera.Transform.Position = new Point(Math.Clamp(camera_pos, -3.5, 3.9), 0);

                        Но так как в SDL нет понятия камеры, приходится, по сути, двигать все остальные спрайты в игровом мире при движении персонажа. Поэтому DeltaTime влияет на вообще все спрайты при вычислении координат.

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

                        UPD: При локе в 15 FPS отчетливо видно, что этот эффект происходит из-за того, что в определенный момент времени все пиксели по оси Y меняют свою фактическую ширину. https://youtu.be/dLct7Rsk0BU

                        UPD2: На уровне SDL это можно решить включением линейного или анизатропного сглаживания, но тогда можно забыть про тайловый pixelart, потому что картинка сильно мылится и и становятся видны края тайлов

                        Скриншот


                      1. haqreu Автор
                        23.09.2021 00:34

                        Так, стоп. Полный назад. Это что за вертикальные дырки в земле? Появляются они крайне регулярно.

                        Это абсолютно точно не вопросы алиасинга, и линейное сглаживание только заметёт пыль под коврик. У вас ресайзинг спрайтов есть где-то? Я не умею в сишарп :(


                      1. EduardGuschin
                        23.09.2021 01:42

                        Да, это тоже проблема. Их природа, скорее всего, такая же как и у остальных плавающих спрайтов. Земля - тайловый спрайт размером 15x15, и чтобы все эти маленькие пиксельартные спрайты отобразить на FullHD и выше, пришлось поработать над игровым масштабированием. Происходит оно следующим образом:

                        При загрузке спрайта земли ему назначается pixelPerUnit = 15; (это на верхнем уровне дает возможность упростить работу с координатной плоскостью, благодаря этому Size спрайта земли равен 1x1, а значит по оси X его можно расставлять на целочисленных координатах без стыков) Область видимости камеры по умолчанию устанавливается на 5.

                        При отрисовке спрайта над его размером и положением проводятся следующие манипуляции:

                        1. Вычисляем Unit (единицу измерения) мира

                          1.1. Берем две точки, (1;0) и (0;0), конвертируем их из мировых в экранные координаты, и вычитаем первую из второй. В итоге у нас получится размер мировой единицы измерения в экранных пикселях.

                        2. Получаем Высоту и Ширину объекта:

                          _size.Width = _bounds.Width * transformTo.LocalScale.X;
                          _size.Height = _bounds.Height * transformTo.LocalScale.Y;
                        3. Вычисляем draw_rect для передачи в SDL:

                          var unit = Camera.MainCamera.WorldUnit;
                          
                          _draw_rect.w = (float)(unit * (_size.Width / PixelPerUnit));
                          _draw_rect.h = (float)(unit * (_size.Height / PixelPerUnit));
                          
                          _draw_rect.x = (float)(point.X - (_draw_rect.w * transformTo.Achor.X));
                          _draw_rect.y = (float)(point.Y - (_draw_rect.h * transformTo.Achor.Y));
                          
                          center.x = (float)(_draw_rect.w * transformTo.Achor.X);
                          center.y = (float)(_draw_rect.h * transformTo.Achor.Y);
                        4. И на основе этих данных рисуем:

                          SDL.SDL_RenderCopyExF(Game.RenderContext, _texture.Instance, ref _bounds.SDLRect, draw_rect, transformTo.Degrees, ref center, flip);

                        В итоге размер тайла в экранных координатах при окне рендера в 1024х768 становится равен 76.8x76.8, и не меняется на протяжении всего рендера.

                        Можно было бы объяснить разрывы в тайлах, если бы в какой-то момент времени SDL на отрисовку передавалась разная ширина тайла, но это не так.


                      1. EduardGuschin
                        23.09.2021 02:38

                        Вы навели меня на одну мысль. По идее, расстояние между двумя тайлами должно быть статично (при моих параметрах это 76,8). Чтобы это проверить, я убрал все тайлы земли кроме двух, поставил их рядом, и начал вычислять их фактическое расстояние между собой. Логи показали интересный результат

                        Лог
                        № спрайта: (ширина; высота) (x; y)
                        
                        
                        2: (76,8; 76,8) (806,1994; 652,8)
                        1: (76,8; 76,8) (729,3994; 652,8)
                        Result: 76,79999
                        
                        2: (76,8; 76,8) (806,1991; 652,8)
                        1: (76,8; 76,8) (729,39905; 652,8)
                        Result: 76,80005
                        
                        2: (76,8; 76,8) (806,1987; 652,8)
                        1: (76,8; 76,8) (729,3987; 652,8)
                        Result: 76,80005
                        
                        2: (76,8; 76,8) (806,19836; 652,8)
                        1: (76,8; 76,8) (729,3984; 652,8)
                        Result: 76,79999
                        
                        2: (76,8; 76,8) (806,198; 652,8)
                        1: (76,8; 76,8) (729,398; 652,8)
                        Result: 76,79999
                        
                        2: (76,8; 76,8) (806,1977; 652,8)
                        1: (76,8; 76,8) (729,3977; 652,8)
                        Result: 76,79999
                        
                        2: (76,8; 76,8) (806,1974; 652,8)
                        1: (76,8; 76,8) (729,39734; 652,8)
                        Result: 76,80005

                        UPD: Я округлил экранные координаты до двух знаков после запятой перед вычислением расстояния, и результат стал еще интереснее

                        Лог
                        2: (76,8; 76,8) (857,1; 652,8)
                        1: (76,8; 76,8) (780,3; 652,8)
                        Result: 76,79999
                        
                        2: (76,8; 76,8) (857,09; 652,8)
                        1: (76,8; 76,8) (780,29; 652,8)
                        Result: 76,80005

                        Если считать данные вручную, получается всегда 76,8, но во время выполнения это не так. Логика float в данном случае мне не понятна.


                      1. haqreu Автор
                        23.09.2021 09:57

                        Да, я и хотел проверить постоянность расстояния между тайлами. Но ничего криминального не вижу в выводе. Что вас смущает? 76.79999 и 76.80005? Это совершенно нормально для float, это 76.8 с машинной точностью.

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

                        Вопрос: а почему субпиксельная точность, почему не целые SDL_Rect? Давайте попробуем передать округлённые значения координат?
                        _draw_rect.x = (float)((int)(point.X - (_draw_rect.w * transformTo.Achor.X) + 0.5));

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


                      1. EduardGuschin
                        23.09.2021 10:21

                        Можно округлить в сторону отрицательной бесконечности через Floor: Можно через Round или (int), эффект будет один и тот же.

                        _draw_rect.w = unit * (_size.Width / PixelPerUnit);
                        _draw_rect.h = unit * (_size.Height / PixelPerUnit);
                        
                        _draw_rect.x = Math.Floor(point.X - _draw_rect.w * transformTo.Achor.X);
                        _draw_rect.y = Math.Floor(point.Y - _draw_rect.h * transformTo.Achor.Y);

                        В таком случае спрайты начинают дергаться целиком: https://youtu.be/56L5giUZL9A

                        Почему так происходит, я показал вот здесь: https://youtu.be/kjoDMnizsdM (здесь для примера не используется ничего, кроме попиксельного перемещения слоев с разной скоростью, чтобы исключить ошибки в DeltaTime и математических функциях отвечающих за плавное перемещение. Нет даже масштабирования текстур, они представлены как есть)

                        Казалось, субпиксельная точность поможет избавиться от такого эффекта

                        Можно даже вместо SDL_RenderCopyExF заюзать SDL_RenderCopyEx, чтобы вообще исключить возможность косяков в коде, но эффект тот-же.


                      1. haqreu Автор
                        23.09.2021 10:34

                        Как раз floor не надо, надо в сторону ближайшего целого, т.е. floor(val + 0.5). Первое видео не видно, оно private.


                      1. EduardGuschin
                        23.09.2021 10:41

                        Попробовал в сторону ближайшего целого через упаковку в int +0.5, эффект тот же, что и на первом видео (сделал public)

                        При этом расстояние между тайлами так же скачет

                        Лог
                        2: (77; 77) (558; 653)
                        1: (77; 77) (482; 653)
                        Result: 76, DeltaTime: 0,01708
                        
                        2: (77; 77) (558; 653)
                        1: (77; 77) (482; 653)
                        Result: 76, DeltaTime: 0,0164386
                        
                        2: (77; 77) (558; 653)
                        1: (77; 77) (482; 653)
                        Result: 76, DeltaTime: 0,016708
                        
                        2: (77; 77) (558; 653)
                        1: (77; 77) (481; 653)
                        Result: 77, DeltaTime: 0,016612
                        
                        2: (77; 77) (558; 653)
                        1: (77; 77) (481; 653)
                        Result: 77, DeltaTime: 0,0167341


                      1. Aldrog
                        23.09.2021 12:14

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


                      1. EduardGuschin
                        23.09.2021 12:26

                        К сожалению, разрыв есть, даже когда размер блока целый.


                      1. EduardGuschin
                        23.09.2021 14:17

                        Все таки, мне кажется, дело не в неправильном округлении, не в масштабировании.

                        Я привязал камеру к кусту, а сам куст начал двигать каждый кадр на 0.0001 по оси X без умножения на DeltaTime.

                        Сначала видно, как плывет куст, но когда камера начинает двигаться за кустом - плыть начинает все остальное окружение. (https://www.youtube.com/watch?v=JJCuxeHgyrQ).

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

                        Проблема в функции SDL_RenderCopyExF, или в враппере SDL для C#.

                        Попробую доказать это, сделаю демку с минимум кода чтобы исключить вообще любые косяки в коде.


                      1. haqreu Автор
                        23.09.2021 17:57

                        ух, а что это вертикальные волны на 1:29??

                        https://youtu.be/JJCuxeHgyrQ?t=89

                        У вас растительность на заднем плане - это один спрайт или тоже составлен из кусочков, как и земля? А табличка с черепом?


                      1. EduardGuschin
                        23.09.2021 18:12

                        Растительность на заднем плане из двух спрайтов. Табличка - один спрайт. Вертикальные волны - это тот самый эффект, который можно заметить и на других видео. Он же дает и пробелы в земле. Я экспериментировал с демкой написанной с нуля, и понял, что эти волны можно получить, если прибавлять к _draw_rect.w любое число или умножать на не целое. Если умножить на целое, волн не будет, но тогда получится эффект, как на видео с параллаксом, где спрайт перемещается не плавно, а заметными рывками, хоть и согласно пиксельной сетке монитора. Плюс ко всему, при перемещении камеры спрайты переходят на следующий пиксель не одновременно, а в разнобой, из за чего складывается ощущение, что спрайты между собой дергаются.


                      1. haqreu Автор
                        23.09.2021 18:20

                        подождите, а если ширина целая, а координата нецелая? Тоже прыжками движется?


                      1. EduardGuschin
                        24.09.2021 01:04

                        Да. Эксперименты показали, что нет вообще никакой разницы, передавать целые координаты или с плавающей точкой, SDL у себя внутри перед отрисовкой их округляет.

                        1. Ширина не целая, координата не целая: движется волнами

                        2. Ширина целая, координата не целая: движется прыжком

                        3. Ширина не целая, координата целая: движется прыжком

                        4. Ширина целая, координата целая: движется прыжком


                      1. haqreu Автор
                        24.09.2021 10:41

                        Так. А теперь следующий вопрос: если спрайт не зумится, то есть, размер прямоугольника отрисовки равен размеру прямоугольника спрайта, координаты плавающие, и включен анизо фильтр, как это всё выглядит?


                      1. EduardGuschin
                        24.09.2021 11:43

                        Спрайт передвигается так же рывками по одному экранному пикселю.


                      1. EduardGuschin
                        24.09.2021 12:35

                        На всякий, скину код в 200 строк. Ничего C#-по зависимого, просто SDL2

                        Код
                        using System;
                        using static SDL2.SDL;
                        using static SDL2.SDL_image;
                        
                        namespace SDLTest
                        {
                            internal static class Program
                            {
                                private static IntPtr _windowContext, _renderContext;
                                private static bool _mainLoop;
                        
                                private static Texture _treeTexture;
                                private static Sprite _treeSprite;
                        
                                private static void Main(string[] args)
                                {
                        
                                    _mainLoop = true;
                        
                                    CreateRenderer();
                        
                                    RenderLoop();
                                }
                                
                                private static void CreateRenderer()
                                {
                                    SDL_Init(SDL_INIT_EVERYTHING);
                                    IMG_Init(IMG_InitFlags.IMG_INIT_PNG);
                                    
                                    
                                    
                                    _windowContext = SDL_CreateWindow("test", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 
                                        1024, 768, SDL_WindowFlags.SDL_WINDOW_SHOWN);
                                    
                                    const SDL_RendererFlags renderFlags = SDL_RendererFlags.SDL_RENDERER_ACCELERATED | 
                                                                          SDL_RendererFlags.SDL_RENDERER_TARGETTEXTURE | 
                                                                          SDL_RendererFlags.SDL_RENDERER_PRESENTVSYNC;
                                    
                                    _renderContext = SDL_CreateRenderer(_windowContext, -1, renderFlags);
                                    
                                    SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "2");
                                }
                                
                                private static void RenderLoop()
                                {
                                    LoadTexture();
                                    
                                    while (_mainLoop)
                                    {
                                        SDL_RenderClear(_renderContext);
                        
                                        PoolEvents();
                        
                                        RenderSprite();
                        
                                        SDL_RenderPresent(_renderContext);
                                    }
                                }
                        
                                private static void LoadTexture()
                                {
                                    _treeTexture = new Texture("tree.png", _renderContext);
                                    _treeSprite = new Sprite(_treeTexture, _renderContext)
                                    {
                                        Width = _treeTexture.Width,
                                        Height = _treeTexture.Height,
                        
                                        //X = 0,
                                        //Y = 0
                                    };
                                    _treeSprite.X = 1024 / 2 - (_treeSprite.Width / 2);
                                    _treeSprite.Y = 768 / 2 - (_treeSprite.Width / 2);
                                }
                        
                                private static void RenderSprite()
                                {
                                    _treeSprite.Draw();
                                    _treeSprite.X += 0.001f;
                                }
                        
                                private static void PoolEvents()
                                {
                                    while (SDL_PollEvent(out SDL_Event e) == 1)
                                    {
                                        PoolWindowEvent(e);
                                        PoolKeyEvent(e);
                                    }
                                }
                        
                                private static void PoolKeyEvent(SDL_Event e)
                                {
                                    if (e.key.type == SDL_EventType.SDL_KEYUP)
                                    {
                                        if (e.key.keysym.sym == SDL_Keycode.SDLK_ESCAPE)
                                            _mainLoop = false;
                                    }
                                }
                        
                                private static void PoolWindowEvent(SDL_Event e)
                                {
                                    if (e.window.windowEvent == SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE)
                                        _mainLoop = false;
                                }
                            }
                        
                            public struct Texture
                            {
                                public Texture(string path, IntPtr renderContext)
                                {
                                    
                                    Instance = IMG_LoadTexture(renderContext, path);
                                    SDL_QueryTexture(Instance, out var format, out var access, out var width, out var height);
                                    Width = width;
                                    Height = height;
                                }
                                
                                public int Width { get; }
                                
                                public int Height { get; }
                        
                                public IntPtr Instance { get; }
                            }
                        
                            public struct Sprite
                            {
                                private readonly Texture _instance;
                                private readonly IntPtr _renderInstance;
                                private SDL_Rect _bounds;
                        
                                public Sprite(Texture texture, IntPtr renderContext)
                                {
                                    _instance = texture;
                                    _renderInstance = renderContext;
                                    X = 0;
                                    Y = 0;
                                    Width = texture.Width;
                                    Height = texture.Height;
                        
                                    _bounds = new SDL_Rect()
                                    {
                                        x = 0,
                                        y = 0,
                                        w = _instance.Width,
                                        h = _instance.Height
                                    };
                                }
                        
                                public float X { get; set; }
                                
                                public float Y { get; set; }
                                
                                public float Width { get; set; }
                                
                                public float Height { get; set; }
                        
                                public void Draw()
                                {
                                    var w = Width;
                                    var h = Height;
                                    var drawRect = new SDL_FRect()
                                    {
                                        x = X,
                                        y = Y,
                                        w = w,
                                        h = h
                                    };
                                    
                                    Console.WriteLine($"{w} {X}");
                        
                                    SDL_RenderCopyExF(_renderInstance, _instance.Instance, ref _bounds, ref drawRect, 0, IntPtr.Zero,
                                        SDL_RendererFlip.SDL_FLIP_NONE);
                                }
                            }
                        }


                      1. EduardGuschin
                        25.09.2021 11:14

                        На самом деле стало плавнее, но для пиксельарта сглаживание не вариант.


                      1. haqreu Автор
                        25.09.2021 19:54

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


                      1. EduardGuschin
                        26.09.2021 03:20

                        Артефакты есть, как не крути. Вопрос, как быть, если спрайты все таки нужно масштабировать? Скиллировать исходные спрайты? Выглядит как костыль.


                      1. EduardGuschin
                        26.09.2021 04:32
                        +3

                        У меня получилось сделать такой же эффект волн на SFML. Получается он, если масштабировать спрайт на нецелое число.

                        Резюмируя: проблема не в SDL и не в коде. Просто так устроен механизм отрисовки. Если цель сделать красивый pixelart с плавным движением на современных разрешениях, лучше использовать спрайты больших размеров в стилистике pixelart и включать сглаживание, а не каноничные pixelart спрайты, и не в коем случае не использовать масштабирование спрайтов на нецелое число, если есть хоть-какая ни будь анимация.

                        Большое вам спасибо за уделенное время, вы очень помогли.


  1. cross_join
    21.09.2021 12:44

    Для демонстрации возможностей фреймворка SDL, наверное, круто. Для внедрения в мозг студента базовых возможностей С++, стандартной библиотеки и структурирования программ на С++, по-моему, не очень. Извините, только частное мнение ведущего курс "SQL/noSQL СУБД за 30 часов" и практикующего разработчика на С++.


    1. haqreu Автор
      21.09.2021 13:14

      А можете раскрыть, что именно и почему плохо для C++?


      1. cross_join
        21.09.2021 13:37

        Слово "плохо" я не говорил :) Подход интересный и по сути своей "обучать играя" правильный. Я бы, наверное, построил курс на создании простой консольной игры с использованием только возможностей стандартной библиотеки с выгрузкой/загрузкой состояния в файл. Студентам, как минимум, пришлось бы изучить все основные структурные блоки (if, for, while, switch), создать свои классы, использовать iostream, концепт << и базовые контейнеры типа списка, массива или даже словаря.


        1. haqreu Автор
          21.09.2021 13:42

          Так ведь именно всё это и придётся студентам сделать. Тот код, что я показал в этой статье - это совсем не конечная цель, а чисто заготовка, чтобы получить открывающееся окно, обработку клавиатуры, и отрисовку спрайтов. Грубо говоря, чисто чтобы студенты не тратили время на написание CMakeLists.txt.

          А дальше мясо они будут накручивать сами, и никак не смогут обойтись без, например, работы с файлами (нужно же уровни грузить?), с памятью (нужно же монстров добавлять?) и т. п.


          1. cross_join
            21.09.2021 13:46

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


            1. haqreu Автор
              21.09.2021 14:22

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

              Ну и к тому же, не обязательно ставить себе целью объять ВСЮ STL, это совершенно не нужно и абсолютно нереально в рамках любого университетского курса.


              1. cross_join
                21.09.2021 14:29

                Не скажите :) Помню, у нас на ДВК-2 была консольная динамическая игра Road. Границы трассы выводились символами "/", "|" и "\", препятствия "солнышком", машина управлялась стрелками. Программировать пошаговые игрушки не менее интересно.


                1. haqreu Автор
                  21.09.2021 14:36
                  +1

                  Угу, только нынче для такого программирования в консоли понадобится какой-нибудь ncurses, что ничем не отличается от SDL.


              1. Tujh
                21.09.2021 14:59

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

                Попробуйте сделать сетевой калькулятор, сервер-клиент. Клиент парсит строку с арифметическим выражением и отправляет сериализованный вывод на сервер, который уже и делает расчёты.

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

                Третий этап - "взломать" (отказ в обслуживании или некорректный результат вместо сообщения об ошибке) сервер (можно ваш, но можно и собственный) некорректным запросом, по типу классического "my name is) DROP TABLE users;" или незакрытым XML/Json тэгом и предложить решение по защите (экранирование).

                Из "лишних" вещей тут только сокеты будут, а не целая SDL, хотя можно клиент на Qt сделать - дополнительный нужный опыт.


                1. haqreu Автор
                  21.09.2021 15:33

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


                  1. Tujh
                    21.09.2021 15:54

                    А какая итоговая специальность у студентов?

                    Как-то С++ для первого курса мне кажется избыточным, я думал речь идёт о третьем (примерно курсе), Python лучше бы подошёл для старта, да даже чистый Си, который тоже вполне себе в ООП может если правильно покрутить.


                    1. haqreu Автор
                      21.09.2021 16:35

                      Про первый курс у меня прямо в первом предложении статьи написано ;)

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


                      1. Tujh
                        21.09.2021 17:35

                        Про первый курс у меня прямо в первом предложении статьи написано ;)

                        Пропустил, извиняюсь. Почему-то в голове засел третий.

                        Специализация - информатика в самом широком смысле

                        Это немного меняет дело. Тут действительно визуальная составляющая играет огромную роль.

                        Тогда следующий вопрос, почему не что-то готовое, я бы посмотрел в сторону LÖVE, правда основано на Lua, но гораздо проще для освоения.


                      1. haqreu Автор
                        21.09.2021 17:56

                        А что может быть готовое помимо LÖVE? Мне нужен ц/цпп, тут у меня вариантов нет (см. жёсткая программа, аккредитованная министерством).


  1. Izaron
    21.09.2021 13:54

    SDL написан на Си, код для работы с ним чисто сишный, на плюсах так не пишут. Графической библиотекой для C++ является, например, SFML.


    1. haqreu Автор
      21.09.2021 14:21

      Ой, несколько сишных вызовов это не страшно. Главное-то всё равно не сама библиотека, а внутреннее представление игры.


  1. amarao
    24.09.2021 00:25
    +1

    Спасибо большое. SDL приятный в работе, и вы тщательно объясняете всё. Если у меня найдётся время (увы, увы, не факт), я попытаюсь портировать ваш код на Rust (который с SDL работает плюс-минус так же).

    У вас забавная лицензия, но если бы вы её сделали под MIT, то остальным было бы проще и понятнее работать с вашим кодом и ресурсами.

    Отдельный вопрос про спрайты. Вы их сами рисовали, или это скриншоты откуда-то? Если сами, лицензия на картинки распространяется?


    1. haqreu Автор
      24.09.2021 00:30

      Спасибо на добром слове. Спрайты рисовал сам, специально, чтобы не зависеть ни от кого. Лицензия общая на всё :)

      Вообще я за то, чтобы отдавать подобное просто в public domain, так что даже MIT мне не очень подходит.

      Если что, у меня где-то векторные черновики спрайтов остались ещё.


      1. amarao
        24.09.2021 21:58

        Сделайте под cc-0 (https://creativecommons.org/share-your-work/public-domain/cc0/) Это специальная лицензия, позволяющая обойти отсутствие понятия добровольной передачи в public domain в некоторых юрисдикциях.