Начнем с очевидного: зачем в принципе может понадобиться делать игру именно на kotlin?

Специалисты по браузерным играм пользуются продвинутыми движками: Unity, Godot, возможно Phaser и другими. Им нужен полноценный редактор, сцены, инспектор, анимационный редактор, тайлмапы, отладчик и большой игровой pipeline.

Но что если ваша работа бесконечно далека от игр, а бизнес резко захотел развлекуху-замануху для пользователей? Вот Timeweb недавно сильно облажался с охлаждением, и буквально за пару дней склипал маленькую игру-оправдашку:

Помоги чиллеру
Помоги чиллеру

Вот это и есть главная сфера применения таких решений. Нужны скорость разработки и сохранение стэка (в нашем случае Kotlin).

Timeweb-дисклеймер

Приведенный выше пример не имеет отношения к данному обзору. Мне искренне нравится Timeweb Apps Platform для frontend приложений за демократичную цену в 1 рубль. Поэтому демо развернуто именно там. Но, пользуясь случаем, кину камень в ваш огород: почините кнопку "Стать автором"! Тотальный игнор заявок не делает вас лучше.

Чтобы посмотреть на возможности KorGE — разработаем на скорость небольшую, но полноценную canvas/web-игру: спрайты, музыка, управление с клавиатуры и телефона, игровой цикл, проигрыш, победа, вот это все. А чем завалена вся лента в последнее время? Правильно, вот этим товарищем:

Футболист Холанд из Норвегии
Футболист Холанд из Норвегии

Поэтому и игра будет Viking Football. По духу это как динозаврик из Chrome, только с другими персонажами. Викинг бежит по футбольному полю, враги едут навстречу: летящие мячи надо ловить, игроков пинать, еду есть. Победа наступает, если продержаться до конца песни. Каждые 20 секунд уровень растет, скорость повышается.

Посмотрим возможности движка KorGE в условно-реальном проекте:

Настройка проекта

KorGE живет в Kotlin/Gradle-мире, умеет собираться под разные таргеты и при этом не требует переходить в отдельный визуальный редактор или писать логику на другом языке.

Первым делом применяется gradle-плагин:

plugins {
    alias(libs.plugins.korge)
}

korge {
    id = "com.karandashov.viking.football"
    name = "Viking Football"
    targetJs()
}

targetJs() включает сборку под браузер. В документации KorGE по Web target описаны задачи jsRun и jsBrowserDistribution, а также то, что web-таргет работает через WebGL и WebAudio. Под капотом это все равно Kotlin/JS, поэтому появляется JavaScript-инфраструктура. Gradle-плагин скачивает npm-зависимости и собирает bundle через webpack.

Сцена фиксированного размера

Входная точка задает основные параметры сцены:

suspend fun main() = Korge(
    windowSize = Size(1280.0, 720.0),
    virtualSize = Size(1280.0, 720.0),
    displayMode = KorgeDisplayMode.CENTER,
    backgroundColor = SkyColor,
    title = "Viking Football",
) {
    VikingFootballGame(this).start()
}

Я выбрал фиксированную виртуальную сцену 1280x720. Для пиксельных ассетов это нормальное решение: можно руками расставлять персонажей, кнопки и HUD, не пересчитывая весь layout под каждый телефон. На реальных мобильных устройствах это сильно упростило жизнь. Но конечно пришлось пожертвовать адаптивным UI, в перспективе можно сделать лучше.

Игровой цикл

В KorGE можно анимировать через tween, coroutine-loop или updater (по времени). Последний вариант как раз подходит для текущей игры: каждый delta time немного сдвигаем состояние и персонажей.

Упрощенно цикл игры выглядит так:

stage.addUpdater { dt ->
    val seconds = dt.seconds

    hero.update(seconds)
    if (state != GameState.RUNNING || portraitBlocked) return@addUpdater

    elapsed += seconds
    val level = floor(elapsed / 20.0).toInt() + 1
    val speed = 150.0 + (level - 1) * 28.0

    background.update(seconds, speed)
    obstacles.forEach { it.update(seconds, speed) }

    // Проигрыш наступает только если активное препятствие ушло за левый край.
    if (obstacles.any { it.canLose && it.leftEdge <= -18.0 }) {
        finish(GameState.LOST)
    }
}

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

Доступно управление через кнопки и клавиши, есть событийный API. Для сенсорных экранов сделаны экранные кнопки через обычные KorGE views и onDown. Для клавиатуры хватило justDown, чтобы действие срабатывало один раз за нажатие:

stage.keys {
    justDown(Key.SPACE) { perform(Action.KICK) }
    justDown(Key.UP) { perform(Action.JUMP) }
    justDown(Key.RIGHT) { perform(Action.EAT) }
}

Спрайты и ассеты

KorGE хорошо работает с frame-based анимациями. Нужна последовательность картинок, которую Sprite может проигрывать. Художник из меня плохой, поэтому ассеты сгенерировал нейронкой:

  • викинг бежит, прыгает, пинает мяч, ест робота;

  • футболист бежит и падает;

  • мяч крутится.

Вот так происходит чтение:

private suspend fun animation(
    path: String,
    frames: Int,
    frameMs: Int,
): SpriteAnimation =
    SpriteAnimation(
        (1..frames).map { index ->
            // Файлы лежат в src/commonMain/resources/assets/sprites
            resourcesVfs[
                "assets/sprites/${path}_${index.toString().padStart(2, '0')}.png"
            ].readBitmapSlice()
        },
        frameMs.milliseconds,
    )

Поддерживаются PNG/JPEG картинки, а для звука есть MP3 и WAV.

Взаимодействие объектов

Самая приятная часть KorGE для такой игры в том, что все остается обычным Kotlin-кодом. Не надо раскладывать логику по визуальным нодам редактора. Нажали кнопку, нашли подходящую цель, изменили состояние объекта, запустили анимацию.

Например, удар по игроку:

private fun perform(action: Action) {
    if (state != GameState.RUNNING || portraitBlocked) return

    hero.playAction(action)

    if (action == Action.KICK) {
        val target = obstacles
            .filter { it.kind == ObstacleKind.PLAYER && it.isActive }
            .minByOrNull { it.x }

        if (target != null) {
            target.resolve()
            hero.kickBallTo(target.x, target.catchY)
        } else {
            // Ранний удар разрешен: мяч просто улетает вперед и возвращается.
            hero.kickBallTo(StageWidth - 58.0, GroundY - 96.0)
        }
    }
}

Состояние препятствия тоже простое: активное едет к герою, resolved проигрывает свою финальную анимацию. Футболист падает, робот “съедается”, пойманный мяч падает за нижний край поля.

Что всплыло в браузере и на телефонах

KorGE закрывает игровой слой, но правила браузера остаются правилами браузера.

Первая проблема: fullscreen. По MDN requestFullscreen, для перехода в полноэкранный режим требуется transient user activation. То есть вызов надо делать из клика или тапа, а не когда игре захотелось.

Вторая проблема: orientation lock. ScreenOrientation.lock на MDN помечен как limited availability. Там же сказано, что блокировка ориентации обычно доступна на мобильных устройствах и часто в fullscreen-контексте. Поэтому надежная стратегия такая: просим landscape, но держим in-game overlay для портретного режима.

Третья проблема: звук. В KorGE Audio docs отдельно указано, что JavaScript использует WebAudio API, а браузеры почти всегда ограничивают его до первого взаимодействия пользователя. MDN в Autoplay guide говорит то же самое: аудио с программным стартом обычно блокируется, пока пользователь не кликнул, не тапнул или не нажал клавишу.

Поэтому старт игры завязан на кнопку. В обработчике я пытаюсь разблокировать аудио, запросить fullscreen/landscape и только потом запускаю музыку:

private fun resetGame() {
    unlockMobileAudio()
    requestLandscapeOrientation()

    // Подгрузка best effort. Игру не блокируем, если браузер отказал.
    runCatching { preloadMusicForMobile("music/compressed.mp3") }

    beginRun()
}

Сами браузерные вызовы лежат в jsMain за expect/actual-оберткой:

internal actual fun requestLandscapeOrientation() {
    js(
        """
        (function () {
          try {
            var orientation = screen.orientation
              || screen.mozOrientation
              || screen.msOrientation;
            if (!orientation || !orientation.lock) return;
            var result = orientation.lock('landscape');
            if (result && result.catch) result.catch(function () {});
          } catch (e) {}
        })()
    """
    )
}

Эта часть проекта выглядит грубее остального Kotlin-кода. Но это неизбежно, хотите жить в браузере — учите js, хотя бы минимально.

Отладка

Отладка игр сильно отличается от привычного backend. В backend очень просто можно написать воспроизводимый тест: пришел запрос, ушел ответ, состояние базы проверили. В игре много субъективных вещей: удобно ли нажимать, читается ли препятствие на скорости, не слишком ли рано начинается проигрыш, нормально ли выглядит падение мяча.

KorGE помогает базовыми вещами. Есть debug режим через runJvm и включение рамок views по F7. Но когда проблема живет в mobile Safari, WebAudio или orientation lock, приходится идти в браузер и устройство.

Фиксированная сцена 1280x720 сильно снизила количество мобильных сюрпризов. На реальных телефонах почти не было расхождений именно из-за того, что игра не пыталась быть адаптивной страницей.

Размер сборки

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

Финальная директория build/dist/js/productionExecutable занимает около 11 МБ. Внутри:

viking-football.js      1.3M 
viking-football.js.map  7.0M
music/compressed.mp3    1.4M
assets/sprites/*.png    1.2M
index.html              4.0K

Js bundle после gzip получился около 439 КБ. Webpack при production-сборке предупредил, что viking-football.js больше рекомендованного лимита 244 KiB. Для маленькой игры это неприятно, но ожидаемо: Kotlin runtime, KorGE/Korlibs и игровой код попадают в bundle. Но мы сделали это за вечер!

Выводы

KorGE хорошо подходит для мини-игр, прототипов, учебных проектов и Kotlin-first pet projects. Особенно если команда уже живет в Kotlin и хочет быстро получить браузерную игру без погружения в чужую экосистему.

Сильные стороны:

  • пишешь почти весь код на Kotlin;

  • Gradle-проект выглядит привычно;

  • общий код можно держать в commonMain, браузерные детали уносить в jsMain;

  • спрайты, ресурсы, input, audio и игровой цикл закрываются средствами движка;

  • для маленькой 2D-игры получается достаточно компактно по коду.

Слабые стороны:

  • сборка ощутимо тяжелее, чем ожидаешь от небольшой браузерной игры;

  • Kotlin/JS тянет npm/Yarn/webpack-инфраструктуру;

  • размер JS bundle не микроскопический;

  • документация KorGE местами выглядит неровно и иногда сама помечена как не полностью проверенная;

  • браузерные ограничения по fullscreen, orientation lock и audio никуда не исчезают.

Главное не ждать от KorGE, что он спрячет всю сложность внутри. Он хорошо дает игровой слой на Kotlin. А дальше все, как обычно: браузер попросит пользовательский gesture, телефон не всегда даст заблокировать ориентацию, сборка принесет webpack, а удобство прыжка придется проверять руками.

Прикольных вам pet-проектов!