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

Реализация такой последовательности в коде должна быть весьма непосредственной – как-никак, компьютер тоже работает по своеобразному сценарию. Но в играх такие сценарии могут выполняться на протяжении нескольких минут, а ведь там, помимо этого, осуществляется много других процессов (звук, анимация и т. д.). В такой ситуации самым очевидным решением будет поместить сценарий в отдельный поток выполнения. Но тогда появляется риск возникновения состояния гонки или других неприятных багов, связанных с потоками выполнения. Что же делать?
Одно из возможных решений – использование конечного автомата. Но переписывание сценария в соответствии с конечным автоматом рискует обернуться сущей пыткой, к тому же в этом случае результирующий код будет гораздо сложнее понять.
Более простым решением, на котором мы сегодня остановимся, будет использование сопрограмм. В двух словах, сопрограмма – это что-то вроде функции, поддерживающей остановку и продолжение выполнения с сохранением определенного положения. Таким образом, можно выполнить какую-либо часть подпрограммы (одной строки сценария), вернуться к основному потоку и затем продолжить выполнение сопрограммы с прежнего положения. Выходит, сопрограмма работает во многом как поток выполнения, но запускается только по команде и с готовностью возвращает выполнение последовательному приложению.
Сопрограммы являются неотъемлемой частью многих языков, таких как Lua. Но если вы решили создать игру на чистом C++, дело обстоит сложнее. Сторонние реализации библиотек, которые можно найти, например, на boost, в основном предназначены для выполнения нескольких тысяч сопрограмм, а значит, они должны быть очень легковесными. Конечно, при таком упоре на производительность страдает простота использования и переносимость этих библиотек.
Для работы со сценарной последовательностью действий в играх, как правило, требуется лишь одна или несколько работающих одновременно сопрограмм. Однако в таком случае библиотекам вовсе не обязательно быть легковесными. Чтобы исправить эту проблему, я решил создать очень простую реализацию сопрограмм, которая по сути является оберткой для std::thread, но с механизмами, обеспечивающими передачу выполнения от внешнего потока к внутреннему (сопрограмме); таким образом за раз выполнялся только один поток. Используя подходящий поток выполнения, мы никоим образом не ограничены в том, что можно делать из потока. Этот подход также хорошо работает в связке с многими другими инструментами вроде отладчика, отображающего все запущенные потоки выполнения в их текущем состоянии. Поскольку вызывающий поток ставится на паузу в то время, когда выполняется сопрограмма, отпадает необходимость использовать мьютексы или какие-либо другие способы синхронизации состояния игры.
Вот что у меня получилось в итоге:
Моя библиотека сопрограмм доступна на Github, не стесняйтесь использовать ее на свое усмотрение. Это единая .hpp/.cpp пара, которая зависит только от Loguru (моей библиотеки журналирования), но вы можете удалить оттуда то, что считаете лишним.

Реализация такой последовательности в коде должна быть весьма непосредственной – как-никак, компьютер тоже работает по своеобразному сценарию. Но в играх такие сценарии могут выполняться на протяжении нескольких минут, а ведь там, помимо этого, осуществляется много других процессов (звук, анимация и т. д.). В такой ситуации самым очевидным решением будет поместить сценарий в отдельный поток выполнения. Но тогда появляется риск возникновения состояния гонки или других неприятных багов, связанных с потоками выполнения. Что же делать?
Одно из возможных решений – использование конечного автомата. Но переписывание сценария в соответствии с конечным автоматом рискует обернуться сущей пыткой, к тому же в этом случае результирующий код будет гораздо сложнее понять.
Более простым решением, на котором мы сегодня остановимся, будет использование сопрограмм. В двух словах, сопрограмма – это что-то вроде функции, поддерживающей остановку и продолжение выполнения с сохранением определенного положения. Таким образом, можно выполнить какую-либо часть подпрограммы (одной строки сценария), вернуться к основному потоку и затем продолжить выполнение сопрограммы с прежнего положения. Выходит, сопрограмма работает во многом как поток выполнения, но запускается только по команде и с готовностью возвращает выполнение последовательному приложению.
Сопрограммы являются неотъемлемой частью многих языков, таких как Lua. Но если вы решили создать игру на чистом C++, дело обстоит сложнее. Сторонние реализации библиотек, которые можно найти, например, на boost, в основном предназначены для выполнения нескольких тысяч сопрограмм, а значит, они должны быть очень легковесными. Конечно, при таком упоре на производительность страдает простота использования и переносимость этих библиотек.
Для работы со сценарной последовательностью действий в играх, как правило, требуется лишь одна или несколько работающих одновременно сопрограмм. Однако в таком случае библиотекам вовсе не обязательно быть легковесными. Чтобы исправить эту проблему, я решил создать очень простую реализацию сопрограмм, которая по сути является оберткой для std::thread, но с механизмами, обеспечивающими передачу выполнения от внешнего потока к внутреннему (сопрограмме); таким образом за раз выполнялся только один поток. Используя подходящий поток выполнения, мы никоим образом не ограничены в том, что можно делать из потока. Этот подход также хорошо работает в связке с многими другими инструментами вроде отладчика, отображающего все запущенные потоки выполнения в их текущем состоянии. Поскольку вызывающий поток ставится на паузу в то время, когда выполняется сопрограмма, отпадает необходимость использовать мьютексы или какие-либо другие способы синхронизации состояния игры.
Вот что у меня получилось в итоге:
GameUnit camera = ...; GameUnit juliet = ...; GameUnit curtains = ...; cr::CoroutineSet coroutine_set; coroutine_set.start("end_scene", [&](cr::InnerControl& ic){ while (!camera.looking_at(juliet)) { camera.turn_towards(juliet); ic.yield(); // Return to the calling thread } juliet.speak("Romeo, I come! This do I drink to thee."); ic.wait_sec(2.0); // Yield to main thread for the next two seconds auto drink_animation = juliet.animate("drink_poison"); ic.wait_for([&](){ return drink_animation.is_done(); }); auto fall_animation = juliet.animate("fall_to_the_ground");; ic.wait_for([&](){ return fall_animation.is_done(); }); ic.wait_sec(1.0); curtains.animate("drop"); ic.wait_sec(2.0); }); // Game loop: for (;;) { double dt = seconds_since_last_frame(); input(); update(dt); coroutine_set.poll(dt); // Allow coroutines to run for a short while paint(); }
Моя библиотека сопрограмм доступна на Github, не стесняйтесь использовать ее на свое усмотрение. Это единая .hpp/.cpp пара, которая зависит только от Loguru (моей библиотеки журналирования), но вы можете удалить оттуда то, что считаете лишним.