Браузерная игра на Kotlin: практический обзор KorGE

от автора

Начнем с очевидного: зачем в принципе может понадобиться делать игру именно на 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()}

Я выбрал фиксированную виртуальную сцену 1280×720. Для пиксельных ассетов это нормальное решение: можно руками расставлять персонажей, кнопки и 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, приходится идти в браузер и устройство.

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

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

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

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

viking-football.js      1.3M viking-football.js.map  7.0Mmusic/compressed.mp3    1.4Massets/sprites/*.png    1.2Mindex.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-проектов!

ссылка на оригинал статьи https://habr.com/ru/articles/1057966/