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 комментарий

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

Да, тож много думал про то как было бы прикольно их использовать. Например, что можно описать какой-нить игровой квест на await условий понятным линейным кодом.
1