Все статьи / Основной цикл программы. Анимация.

В статье мы освоим паттерн Game Loop, а также научимся обрабатывать события мыши и клавиатуры.


Создайте каталог с названием sfml.2. Откройте каталог в Visual Studio Code. Откройте терминал и убедитесь, что вы находитесь в этом каталоге:

Скриншот

Все упражнения и задания вы будете выполнять в подкаталогах каталога sfml.2: sfml.2\00, sfml.2\01 и так далее.

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

Игра Fizz-Buzz

Когда американские дети изучают числа, они играют в игру Fizz-Buzz:

  • ведущий считает от 1 до 100
  • когда называют число, кратное трём, дети должны сказать “Fizz”
  • если названо число, кратное 5, дети должны сказать “Buzz”
  • если число кратно и 3, и 5, надо сказать “FizzBuzz”
  • иначе надо назвать число

Давайте напишем программу, которая играет в FizzBuzz сама с собой!

Создайте в каталоге sfml.2 файл CMakeLists.txt, перепишите в него текст:

# Минимальная версия CMake: 3.8
cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

# Имя проекта: sfml-lab-2
project(sfml-lab-2)

# Подкаталог 00 содержит ещё один CMakeLists.txt
add_subdirectory(00)

Создайте подкаталог 00 в каталоге sfml.2. В подкаталоге 00 создайте ещё один файл CMakeLists.txt, и скопируйте в него текст:

# Добавляем исполняемый файл 00
# Он собирается из 1 файла исходного кода: main.cpp
add_executable(00 main.cpp)

# включаем режим C++17 для цели сборки 00
target_compile_features(00 PUBLIC cxx_std_17)

Сейчас каталог должен выглядеть так:

Скриншот

Затем создайте файл main.cpp и опишите в нём псевдокод:

#include <iostream>

int main()
{
    // в цикле от 1 до 100
    //  - если число кратно и 3, и 5, вывести FizzBuzz
    //  - иначе если число кратно 3, вывести Fizz
    //  - иначе если число кратно 5, вывести Buzz
    //  - иначе вывести число
}

Цикл while

Теперь мы будем раскрывать псевдокод шаг за шагом. Сначала разберёмся с циклами, которые в C++ представлены ключевыми словами while и for. Синтаксис:

// цикл, пока выполняется условие
// тело — это одна инструкция или блок
while (условие) инструкция;

// цикл с блоком инструкций
while (условие)
{
    инструкция_1;
    инструкция_2;
}

// цикл выводит числа от 1 до 100
int num = 1;
// условие: пока num меньше или равно 100
while (num <= 100)
{
    std::cout << num << std::endl;
    // добавляем к числу 1
    // оператор += это сокращение от `num = num + 1`
    num += 1;
}

Последний цикл перепишите в main вместо комментария “в цикле от 1 до 100”, а остальные комментарии пока поместите в тело цикла:

Код

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

cmake -G "MinGW Makefiles"
cmake --build .

Если сборка прошла успешно и ошибок не было, запустите программу. Она должна просто вывести числа от 1 до 100 включительно.

Цикл for

Цикл for имеет две формы: классическая и range-based. Мы рассмотрим классическую:

for (начальное_действие; условие; действие_в_конце_итерации)
    инструкция_или_блок

// начальное действие: объявить переменную num, равную 1
// условие: пока num меньше или равно 100
// после каждого шага (итерации) цикла добавляем к num единицу
for (int num = 1; num <= 100; num += 1)
{
    // выводим num
    std::cout << num << std::endl;
}

Вернитесь к main.cpp и замените цикл while на цикл for:

Код

Соберите программу командой cmake --build . (не забудьте про точку в конце команды). Если программа собралась успешно и ошибок не было, запустите программу командой 00\00. Она должна снова вывести числа от 1 до 100 включительно.

Деление с остатком и инструкция if

Чтобы определить делимость, мы воспользуемся делением с остатком. Эта одна из 5 арифметических операций в C++:

  • 16 + 3 == 19, 16 - 3 == 13 — сложение и вычитание чисел
  • 16 * 3 = 48, 16 / 3 = 5 — умножение и деление чисел (при делении целых чисел остаток отбрасывается)
  • 16 % 3 == 1 — получение остатка от деления, также называют “взять модуль числа 16 по числу 3”

Для условного выполнения печати мы применим инструкцию if/else. Её синтаксис:

if (условие)
    инструкция_или_блок
else
    инструкция_или_блок

// пример: если число меньше 10, печатаем его
if (num < 10)
{
    std::cout << num << std::endl;
}

Цепочку инструкций else мы объединим, записывая .. else if .. в одной строке. В первом варианте код будет выглядеть так:

Код

Перепишите код в “main.cpp”, затем соберите командой cmake --build . (не забывайте точку в конце), и запустите программу “00\00”. Обратите внимание на вывод районе чисел 12..17:

Fizz
13
14
Fizz
16
17

К сожалению, вывод программы неправилен: для числа 15 надо вывести “FizzBuzz”, а выведено “Fizz”. Мы исправим это дополнительным условием: если число кратно и 3, и 5, то выведем “FizzBuzz”. Воспользуемся логическими операциями в C++:

  • a || b — логическое ИЛИ, истинно если хотя бы одно из двух условий “a” и “b” истинно
  • a && b — логическое И, истинно только если оба условия “a” и “b” истинны

Есть другой способ: число, которое делится на 3 и на 5, делится на 15, и наоборот. Но мы будем использовать операцию “логическое И” в целях тренировки.

Доработайте код следующим образом:

Код

Перепишите код в “main.cpp”, затем соберите командой cmake --build . (не забывайте точку в конце), и запустите программу “00\00”. Теперь программа должна работать правильно для всех чисел 1..100.

Сохраним результат

Теперь создайте репозиторий git командой git init (выполнить команду надо в каталоге sfml.2).

Затем добавьте написанные вами файлы под контроль версий:

git add CMakeLists.txt
git add 00/CMakeLists.txt
git add 00/main.cpp

Теперь введите команду git status и посмотрите на её вывод. Зелёным цветом помечены файлы, добавленные под контроль версий, красным помечены остальные файлы. Если вывод команды у вас совпадает со скриншотом, можно продолжить.

Скриншот

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

Затем введите команду git commit -m "Added FizzBuzz program". Git зафиксирует версию и выведет краткий отчёт.

Возможно, git откажется выполнять фиксацию и предложит ввести свой email и имя. Ему нужны эти данные, чтобы у каждой версии был указан автор изменений. Вы можете ввести данные двумя командами, заменив "ваш.email" и "ваше.имя" на ваши email и имя, например "admin@localhost.ru" и "Lavrentiy Pavlovich"

git config --global user.email "ваш.email"
git config --global user.name "ваше.имя"

Цикл рисования и обработки событий

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

  • мониторы обновляются с частотой ~60 Герц (60 кадров в секунду)
  • контроллер мыши присылает уведомления об изменении положения мыши
  • контроллер клавиатуры присылает состояние клавиш
  • каждая программа старается получить процессорное время, чтобы выполнить свои задачи

В таких условиях операционной системе (ОС) приходится ловко манипулировать множеством событий и потоков данных. Например, 60 раз в секунду система собирает изображения всех окон, рисует их поверх друг друга в одну картинку рабочего стола и выводит на экран.

Чтобы ОС могла выполнить свою задачу, программа должна ей помогать: вести общение с системой в режиме интерактивного диалога, реагируя на переданные окну события ввода и рисуя новые кадры со скоростью ~60 кадров в секунду.

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

  1. обработка событий
  2. рисование нового кадра

Поскольку за один кадр может прийти несколько событий, цикла будет два: один внутри другого. Вся эта схема называется циклом событий (event loop).

Создаём приложение с event loop

Откройте файл sfml.2\CMakeLists.txt, добавьте строку add_subdirectory(01), чтобы он выглядел так:

# Минимальная версия CMake: 3.8
cmake_minimum_required(VERSION 3.8 FATAL_ERROR)

# Имя проекта: sfml-lab-2
project(sfml-lab-2)

# Каждый подкаталог содержит CMakeLists.txt
add_subdirectory(00)
add_subdirectory(01)

Создайте в каталоге sfml.2 подкаталог 01, и создайте в нём файлы 01\CMakeLists.txt, 01\main.cpp.

Перепишите в файл sfml1.2\01\CMakeLists.txt следующий скрипт:

add_executable(01 main.cpp)

set(SFML_STATIC_LIBRARIES TRUE)

find_package(Freetype REQUIRED)
find_package(JPEG REQUIRED)
find_package(SFML 2 COMPONENTS window graphics system REQUIRED)

target_include_directories(01 PRIVATE ${SFML_INCLUDE_DIR})
target_compile_features(01 PUBLIC cxx_std_17)
target_compile_definitions(01 PRIVATE SFML_STATIC)

target_link_libraries(01 ${SFML_LIBRARIES} ${SFML_DEPENDENCIES})

Теперь перепишите код в файл sfml1.2\01\main.cpp:

Код

Соберите проект через CMake и запустите 01\01.exe. Посмотрите на результат. Попробуйте сделать скриншот окна. Закройте окно, нажав на кнопку “закрыть” (крест в углу окна). Как вы думаете, какой фрагмент кода в цикле событий позволяет закрывать окно?

Код

Добавляем движение

Цикл выполняется постоянно, и непрерывно отправляет операционной системе новые кадры — это происходит при вызове window.display(). Для начала мы попробуем на каждом шаге цикла прибавлять значение к координате x позиции круга. Добавьте этот код перед вызовом window.clear():

sf::Vector2f position = shape.getPosition();
position.x += 0.5;
shape.setPosition(position);

Тип данных sf::Vector2f — это вектор из двух чисел, он имеет поля x и y, а также поддерживает основные арифметические операции: сложение векторов, умножение вектора на число, деление вектора на число, вычитание векторов.

Соберите программу через CMake и запустите её. Вы увидите, что шар быстро улетает в строну, причём на разных машинах он может лететь с разной скоростью.

Почему так происходит? Потому мы не учли промежутки времени: прибавлять на каждом кадре 0.5px нельзя, если мы не знаем число кадром или если оно может меняться.

Чтобы получить изменение местоположения численным методом, мы должны умножить текущую скорость на длину интервала времени. Чтобы это осознать, представьте себе разгоняющийся автомобиль, который вы фотографируете каждые 0.3 секунды. Если совместить фотографии, автомобиль будет выглядеть примерно так:

Иллюстрация

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

Для замера времени воспользуемся классом sf::Clock. Он имеет метод restart(), который перезапускает часы и возвращает прошедшее с предыдущего перезапуска число секунд, хранимое в типе данных sf::Time. Получить число секунд в виде float можно вызовом метода asSeconds(). Доработайте код:

Иллюстрация

Соберите и запустите программу. Двигается ли шарик плавно? Совпадает ли на ваш взгляд его скорость с заданной скоростью?

Шаблон проектирования “Игровой Цикл” (Game Loop)

Фактически мы реализовали игровой цикл. Шаблон Game Loop он расширяет обычный цикл событий, и схематически выглядит так:

Иллюстрация

Фактически Game Loop требует, чтобы в цикле событий было три явно выраженных шага:

  1. Вложенный цикл обработки событий, который должен среагировать на закрытие программы, на события мыши или клавиатуры
  2. Шаг обновления состояния, который получает прошедший интервал времени и меняет состояние программы — то есть состояние всех переменных, хранящих модель процесса, который программа симулирует.
  3. Шаг рисования, который может менять состояние некоторых переменных, но не должен задевать переменные, входящие в модель процесса

Воспользуемся векторной алгеброй

Библиотека SFML предоставляет готовые средства для работы с векторами вместо обычных значений. В частности, класс sf::Vector2f поддерживает привычные арифметические операции сложения, умножения, деления и вычитания — как с другими векторами, так и с целыми числами. Мы воспользуемся этим и будем представлять скорость не в виде числа, а в виде вектора. Перепишите код:

Код

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

Добавляем отталкивание от стенок

Чтобы шар отталкивался от стенок, мы добавим серию проверок через if.

Сначала внесите два изменения до цикла:

  1. Переместите объявление переменной speed в любое место до начала цикла
    • мы перемещаем объявление до начала цикла, потому что изменения переменной на каждом шаге цикла должны накапливаться, а не сбрасываться
    • напомним, синтаксис объявления тип_данных имя_переменной = ...;
    • в нашем случае тип данных — вектор без атрибута const, то есть sf::Vector2f, имя переменной — speed, и переменная инициализируется парой чисел { 50.f, 15.f }
  2. Объявите константу constexpr float BALL_SIZE = 40; до начала цикла; в объявлении sf::CircleShape shape(40); замените 40 на BALL_SIZE
  3. Точно также объявите константы для размеров окна и замените числа на константы в объявлении переменной window:
constexpr unsigned WINDOW_WIDTH = 800;
constexpr unsigned WINDOW_HEIGHT = 600;

Затем доработайте основной цикл:

Код

Будьте внимательны: переменная speed должна объявляться до начала цикла, иначе на каждой итерации цикла она будет инициализироваться заново, и отталкивания не произойдёт!

Соберите приложение и запустите. Шар должен отталкиваться от границ окна

Теперь зафиксируйте результат: добавьте новые файлы с помощью git add, затем выполните git commit -m "Added flying ball". Проверьте историю изменений с помощью git log: в этой истории должна быть запись “Added flying ball”.

Численный метод против аналитического

Способ расчёта движения, который мы применили, называют “итеративным” либо “численным”. Мы накапливаем изменение местоположения, умножая скорость на deltaTime на каждой итерации цикла:

  • speed = speed + acceleration * deltaTime, где speed - скорость, acceleration - ускорение, deltaTime - время, прошедшее с предыдущего кадра
  • position = position + speed * deltaTime

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

Итеративный подход имеет важный плюс:

  • можно задать даже очень сложное движение: на каждом кадре мы просто рассчитываем скорость, исходя из текущих сил и взаимодействий, а затем умножаем скорость на deltaTime

Есть минус, хотя он незначителен в нашем случае:

  • численный подход накапливает погрешность вычислений

Аналитический метод противоположен численному. Согласно этому методу, на каждом кадре мы делаем следующее:

  1. получаем полное время, прошедшее с начала всей симуляции
  2. затем используем аналитическую формулу для расчёта местоположения

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

  1. Если объект находится под влиянием сил, то
    • рассчитываем сумму ускорений
    • затем вычисляем скорость выражением speed = speed + acceleration * deltaTime
    • если мы симулируем абсолютно упругий удар, можно заменить скорость на противоположную (т.е. отразить относительно касательной к фигурам в месте столкновения)
    • затем вычисляем местоположение выражением position = position + speed * deltaTime
  2. Если же мы знаем аналитическую формулу в виде функции position = f(time), мы можем получить производную функции f, что даёт нам метод расчёта скорости: speed = f'(time); после этого местоположение можно вычислять итеративно

Волновое движение

Создайте в каталоге sfml.2 подкаталог 02, в нём создайте CMakeLists.txt аналогично предыдущим упражнениям. Создайте в каталоге 02 файл main.cpp, далее мы будем работать в нём.

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

Иллюстрация

В C++ тригонометрические функции доступны в заголовке cmath под именами std::sin и std::cos.

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

constexpr float speedX = 100.f;
constexpr float amplitudeY = 80.f;
constexpr float periodY = 2;

Мы также будем вычислять координаты на основе только лишь времени. Вспомогательная переменная wavePhase — это фаза волны, которую мы поделим на период, чтобы фаза повторялась раз в periodY секунд:

const float time = clock.getElapsedTime().asSeconds();
const float wavePhase = time * float(2 * M_PI);
const float x = speedX * time;
const float y = amplitudeY * std::sin(wavePhase / periodY);

Константа M_PI объявлена в заголовке cmath (строго говоря, M_PI — это макрос). Константа задаёт число π в формате двойной точности (double, в два раза точнее чем float). Мы используем выражение float(2 * M_PI), чтобы умножить π на 2 и привести к формату float.

Перепишите следующий код:

Код

Теперь зафиксируйте результат: добавьте новые файлы с помощью git add, затем выполните git commit -m "Added sinusoid motion". Проверьте историю изменений с помощью git log: в этой истории должна быть запись “Added sinusoid motion”.

Задание sfml2.1: волновое движение с отталкиванием

  1. Создайте в каталоге sfml.2 подкаталог sfml2.1, в нём создайте CMakeLists.txt аналогично предыдущим упражнениям
  2. Создайте в каталоге sfml2.1 файл main.cpp и в нём совместите волновое движение шарика с отталкиванием от стенок по оси Ox.

Подсказка: лучше использовать численный метод расчёта скорости и местоположения, а не аналитический. Другими словами, надо вычислять deltaTime на каждом кадре, перезапуская часы через restart(), и накапливать значение скорости и положения на каждой итерации цикла. Вы также можете комбинировать методы: position.x рассчитывать численным методом с учётом отталкиваний, а position.y рассчитывать аналитически без отталкиваний.

Наглядная тригонометрия

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

В полярных координатах местоположение точки определяется двумя числами: углом и расстоянием от начала координат. Такую пару (angle, radius) тоже можно считать вектором и хранить в sf::Vector2f!

Обычно считают, что угол 0° соответствует направлению “вправо”. Так выглядит координатная разметка для полярных координат:

Иллюстрация

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

Иллюстрация

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

x = radius ∙ cos(angle);
y = radius ∙ sin(angle);

В языке C++ это выглядит немного иначе:

// angle - угол, заданный в радианах
const float x = radius * std::cos(angle);
const float y = radius * std::sin(angle);

// перевод градусов в радианы:
const float radians = degrees * float(M_PI) / 180.f;

// перевод радианов в градусы
const float degrees = radians * 180.f / float(M_PI);

Перейти обратно в полярные координаты немного сложнее:

Иллюстрация

Вычислить радиус можно по теореме Пифагора. Чтобы вычислить угол, надо вспомнить, что тангенс — это отношение противолежащего катета к прилежащему. Однако, у тангенса есть две проблемы:

  1. Тангенс не определён для 90° и -90°
  2. Для противоположных углов (например, 70° и -110°) тангенс одинаковый, и отличит эти углы по тангенсу невозможно

Поэтому в C++ есть две функции для расчёта тангенса, и обе определены в заголовке cmath:

  1. std::atan(t) принимает тангенс и возвращает угол, она обладает недостатками, указанными выше
  2. std::atan2(y, x) принимает координаты y и x (именно в таком порядке, т.к. это числитель и знаменатель в формуле тангенса) и возвращает корректный угол, измеряемый в радианах, диапазоне [-π; π]

В итоге корректный переход из декартовых координат в полярные выглядит так:

const float radius = std::sqrt(x * x + y * y);
const float angle = atan2(y, x);

Рисуем окружность через ConvexShape

Создайте в каталоге sfml.2 подкаталог 03, в нём создайте CMakeLists.txt аналогично предыдущим упражнениям. Создайте в каталоге 03 файл main.cpp, далее мы будем работать в нём.

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

Инициализацию окружности можно выполнить до основного цикла. В псевдокоде инициализация выглядит так:

int main()
{
    // Инициализация окружности:
    //  - объявляем переменную типа sf::ConvexShape
    //  - в цикле по i от 1 до N, где N - точность рисования круга
    //    1. вычисляем angle как (2 * π * i / N)
    //    2. устанавливаем radius равным единице
    //    3. переводим координаты из полярных (angle, radius)
    //       в декартовы (x, y)
    //    4. добавляем точку в ConvexShape с номером i, используя
    //       координаты (x, y)

    // Основной цикл:
    // 1. обработка событий (вложенный цикл)
    // 2. обновление состояния (перемещение)
    // 3. рисование
}

Иллюстрация в полярных координатах:

Иллюстрация

Опираясь на эту иллюстрацию, можем вывести способ перевода полярных координат в декартовы:

// полярные координаты
float angle = ...;
float radius = ...;
// получаем декартовы координаты
sf::Vector2f point = {
    radius * std::sin(angle),
    radius * std::cos(angle)
};

Применим этот метод в коде, чтобы заполнить ConvexShape двадцатью точками, расположенными на окружности:

Код

Соберите и запустите этот пример. Убедитесь, что программа рисует круг:

Скриншот

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

Рисуем эллипс

Теперь мы сделаем то, для чего SFML нет готового решения: нарисуем эллипс. Это сделать несложно, достаточно заменить постоянный радиус на вектор, хранящий радиусы по осям Ox и Oy: const sf::Vector2f ellipseRadius = { 200.f, 80.f };.

Также мы повысим качество рисования: увеличим значение константы pointCount до 200 и воспользуемся специальным конструктором sf::RenderWindow, который принимает параметры в виде sf::ContextSettings. В эти параметры мы добавим antialiasingLevel, что обеспечит нам автоматическое сглаживание (antialiasing) краёв фигур за счёт рисования промежуточных полупрозрачных цветов на краях.

// Создаём окно с параметрами сглаживания
sf::ContextSettings settings;
settings.antialiasingLevel = 8;
sf::RenderWindow window(
    sf::VideoMode({ 800, 600 }), "Ellipse",
    sf::Style::Default, settings);

Доработайте код предыдущего примера таким образом:

Код

Соберите и запустите этот пример. Убедитесь, что программа рисует эллипс:

Скриншот

Задание sfml2.2: полярная роза

Примечание: это задание спекулирует особенностями sf::ConvexShape. Класс ConvexShape рисует правильно только выпуклые фигуры (convex shapes), а полярная роза не является выпуклой. Тем не менее, из-за особенностей алгоритма рисования в ConvexShape библиотека SFML нормально рисует полярную розу.

  1. Создайте в каталоге sfml.2 подкаталог sfml2.2, в нём создайте CMakeLists.txt аналогично предыдущим упражнениям
  2. Создайте в каталоге sfml2.2 файл main.cpp и в него скопируйте код, рисующий эллипс.
  3. Доработайте этот пример, чтобы для вычисления радиуса использовалась функция от угла: 200 * sin(6 * angle)
  4. Доработайте пример, чтобы полярная роза двигалась по окружности в пределах окна.

Полярная роза, которая у вас получится, показана на скриншоте. Цвет установлен в sf::Color(0xFF, 0x09, 0x80), но вы можете поменять цвет на свой вкус. Рекомендуется использовать для этого online color picker. Разукрасьте розу под свой вкус!

Иллюстрация