Kotlin, KorGE и корутины для организации игрового цикла.

Я вчера решил ради интереса посмотреть движок KorGE. Он довольно простой, для 2д игр, поддерживает сборку под разные платформы типа мобильных и веб.

Что меня заинтересовало — в нём активно используются корутины!


И тут я позволю себе лирическое отступление. Тот язык, на котором люди пишут что-то для UE, хоть и называется C++, очень далёк от идиоматического языка. Своя стандартная коллекция, свои классы движка, макросы для объектов движка и сборка мусора — получается страшный франкенштейн, когда поверх сложности С++ наслаивается сложность всех велосипедов, что навертели поверх него. Или например паттерн ECS — он прекрасен и хорошо подходит для геймдева, но в С++ как язык буквально зашито ООП и модель объекта с полями и нет никакой поддержки ECS в синтаксисе и компиляторе и стандартной библиотеке. Создатели языка не предполагали, что поля объекта будут не разложены внутри объекта, а будут раскиданы по разным местам.

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

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

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

Гипотетический пример для шахмат (я сейчас не про реализацию в KorGE, а вообще про корутины):


suspend fun doChessParty(isPlayerFirst: Boolean) {
  val board = new Board()

  while(true) { 
    val turn = if (isPlayerFirst) {
      waitPlayerInput(board)
    } else {
      chooseAiTurn(board)
    }
    isPlayerFirst = !isPlayerFirst;
    showTurnAnimation(turn, board)
    if (board.isFinished) break;
  } 

  showGameEnd(board)
}

Что мы видим? Это красивое, абстрактное описание алгоритма. Мы не думаем про обновление кадров, мы не думаем кто кого вызывает

А например showGameEnd() может выглядеть так:


suspend fun showGameEnd(board: Board) {
    val message = makeMessage(board)
    for(i in 1..10) {
        drawAnimatedMessage(i, message)
        waitNextFrame();
    }
}

Функция типа waitNextFrame() приостанавливает корутину и ждут, когда её снова продолжат.

Кто-то раз за разом в игровом цикле вызывает снаружи coroutine.next(), короутина доходит до следующей точки (например, ожидания хода игрока или следующего фрейма) и снова останавливается. Этот код простой и описывает саму суть происходящего.
Машина состояний и стейт с переменными типа isPlayerFirst, board и места исполнения тут всё равно будут, но ими занимается компилятор, а не программист.

Что ещё интереснее — корутины можно отменять. Например, если игрок захочет выйти из игры по alt+f4, мы просто отменим корутину. Если же мы захотим обработать выход, то мы можем поймать CoroutineCancelledException внутри корутины и обработать это — например, для сохранения состояния.

По моему, корутины это довольно красивое и простое решение для некоторых случаев. Что вы думаете об этом?
  • 1

2 комментария

KCEPOKC
А ещё стейт корутин можно сериализовать и таким макаром сделать сохранения игры.

Да, тож много думал про то как было бы прикольно их использовать. Например, что можно описать какой-нить игровой квест на await условий понятным линейным кодом.
1
sam0delk1n
По моему, корутины это довольно красивое и простое решение для некоторых случаев. Что вы думаете об этом?
Думаю, что «некоторых случаев» реально не так и много.

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

Если рассуждать шире, концепция цикла и «длинных» корутин как-то не очень вяжутся. Если представить себе блок-схему цикла, логические ветви внутри итерации цикла должны так или иначе возвратиться к условию цикла, а корутина какбы вываливается из него по времени. Да, здесь проблема не столько в корутине, сколько в том, что она выполняется в другом потоке и время её выполнения может выходить за пределы времени выполнения итерации цикла. МП программу сложно представить в виде формальной блок-схемы, но смысл происходящего, я думаю, понятен. Решением здесь является ожидание выполнения работы всех корутин до начала новой итерации цикла, но в таком случае концепция обработки квестов и других игровых состояний дольше одного кадра к корутинам уже не применима.

Честно говоря, я сомнительно отношусь к тенденции применять универсальные подходы на разных уровнях разработки ПО. Разработка движка может подразумевать концепцию DOD, ручной менеджмент памяти и соответствующий язык, например даже просто С. Здесь нет формального деления игры на состояния типа «главное меню», «игровой цикл», «переход на новый уровень» — есть один большой главный цикл, который параметризуется входными данными. Разработка игры на движке — это уже другие подходы и концепции, например можно ограничиться исключительно графическим интерфейсом, и программисты С здесь не нужны. Более полезен будет скриптовый интерпретируемый язык, с которым можно работать как в режиме REPL, так и компилировать в байткод. Именно здесь можно применять длинные корутины, каждая будет исполняться в своём окружении, контролироваться и синхронизироваться через ядро движка и его API.

Впрочем, реальность состоит не из концепций — нужно видеть и оценивать конкретное решение.
0