Возможность компиляции Kotlin в нативный код, который может использовать С-библиотеки позволяет разрабатывать мультимедийные приложения и игры на основе библиотек SDL, GTK/OpenGL, GDX и специализированных библиотек для Kotlin (например, KorGE). В этой статье мы последовательно создадим кроссплатформенную реализацию Pacman (как для мобильных платформ, так и для компьютеров на Windows / Linux / MacOS).

Прежде всего нужно обозначить как именно Kotlin Native позволяет выполнить компиляцию исходного кода в исполняемый образ для целевой операционной системы. Компилятор Kotlin Native (konanc) основан на стеке llvm и используют платформенно-специфические инструменты для сборки исполняемого артефакта (инструменты командной строки XCode для iOS / MacOS / TvOS, инструменты toolchain gcc + ld для остальных систем). Технически компиляция происходит в несколько этапов:

  • преобразование во внутреннее представление (IR) в LLVM Frontend на основе абстрактного синтаксического дерева, построенного из исходных текстов.

  • компиляция в двоичный артефакт (объектный файл) для целевой аппаратной архитектуры (используются возможности toolchain).

  • связывание с другими объектными файлами и создание исполняемого файла или библиотеки для подключения к внешним приложениям.

Поскольку представление структур данных и строк в Kotlin отличается от C, на этапе вызова методов и получения результатов будет необходимо использовать методы преобразования, которые предоставлены пакетами kotlinx.cinterop. Также отдельного внимания требует управление памятью (получение указателей, работа с указателями и приведение типа, выделение и освобождение памяти), которое реализуется отдельным механизмом в Kotlin Native. Также при компиляции Kotlin Native используются альтернативные реализации стандартной библиотеки (ввод-вывод, операции с коллекциями и строками, математические операции и др.) благодаря поддержке модификаторов except-actual (связывает интерфейс класса и его методов и платформенную реализацию для конкретной операционной системы).

Начнем с установки Kotlin Native и компиляции простого приложения в исполняемый файл. В статье мы будем использовать возможности компиляции через утилиты командной строки, но также доступна и интеграция в задачу gradle, в этом случае необходимо создать мультиплатформенный проект, создать ветки для сборки в программно-аппаратную архитектуру, например для MacOS ветка в Gradle будет выглядеть следующим образом:

kotlin {
    macosX64("native").apply {
        binaries {
            executable {
                entryPoint = "main"
            }
        }
    }
    sourceSets {
        val nativeMain by getting
        val nativeTest by getting
    }
}

Если необходимо подключить сторонние библиотеки, необходимо выполнять ряд подготовительных операций

  1. На основе header-файлов библиотеки создать файл .klib (содержит сигнатуры экспортируемых функций и представляет их как часть Kotlin-пакета, а также двоичный образ скомпилированной библиотеки для указанной архитектуры). Для создания klib-файла используется утилита cinterop из Kotlin Native CLI и def-файл с описанием исходных header-файлов и параметров компиляции. Альтернативно для компиляции klib можно использовать следующий фрагмент кода в описании сборки для платформы (для сборки libcurl на основе заголовочных файлов):

hostTarget.apply {
    compilations["main"].cinterops {
        val libcurl by creating {
            when (preset) {
                presets["macosX64"] -> includeDirs.headerFilterOnly("/opt/local/include", "/usr/local/include")
                presets["linuxX64"] -> includeDirs.headerFilterOnly("/usr/include", "/usr/include/x86_64-linux-gnu")
                presets["mingwX64"] -> includeDirs.headerFilterOnly(mingwPath.resolve("include"))
            }
        }
    }
}
  1. Импортировать библиотеки и использовать методы из kotlinx.cinterop для работы со структурами данных, указателями, выделения и освобождения памяти, строками.

  2. Подключить klib файлы в gradle через implementation(files('<path_to_klib>'))

Для компиляции в klib можно использовать исходные тексты на Kotlin:

kotlinc-native test.kt -p library -o test

создает файл test.klib, в котором будет размещено промежуточный результат компиляции исходных кодов в IR и описание всех экспортированных функций. Альтернативно можно использовать исходные коды на другом языке (например, C) и выполнить сборку библиотеки и создание klib-файла на основе h-файла с описанием интерфейса и def-файла для метаданных и параметров компилятора.

Создадим простой проект на C и соответствующий заголовочный файл:

int sum(int a, int b) {
  return a + b;
}
int sum(int, int);

и создадим статическую библиотеку из исходных кодов:

gcc -c test.c -o test.o
ar rcs test.a test.o

подготовим файл с описанием метаданных для сборки klib:

headers = test.h
package = test
staticLibraries = test.a
libraryPaths = .

и создадим klib-файл на основе файла с заголовками функций и скомпилированным двоичным образом:

cinterop -def test.def -compiler-options -I`pwd` -o test

установим созданный пакет в репозиторий klib:

klib install test.klib

проверим использование нативного кода простой функцией:

import test.*

fun main() {
  println(test.sum(5,7))
}

выполним компиляцию в нативное приложение и проверим его работу:

kotlinc-native -l test sample.kt -o sample
./sample

после запуска мы получим в консоли вывод суммы (число 12). Аналогично можно использовать связывание с динамической библиотекой (в этом случае будет необходимо указать путь к расположению .so / .dylib-файла во время выполнения сборки приложения), на примере MacOS:

gcc -dynamiclib -o test.dylib test.c

или для Linux:

g++ -shared -o test.so test.c

и выполним сборку с использованием динамической библиотеки:

kotlinc-native -l test sample.kt -o sample -linker-options "-L`pwd` -ltest"

после запуска получим аналогичный результат (12).

Теперь, когда мы рассмотрели основные моменты сборки klib для встраивания внешних C-библиотек, можем перейти к примерам с использованием библиотек для поддержки мультимедиа и создания игр. Единственное отличие для использования существующих библиотек - при выполнении cinterop необходимо указать корректное расположение каталога с header-файлами библиотеки (в Linux обычно /usr/include), а при сборке - расположение so/dylib - файлов (в linker-options, чаще всего /usr/lib). 

GTK

Наиболее очевидным кроссплатформенным решением для создания графических приложений является библиотека GTK. Библиотека предлагает большой выбор готовых виджетов для наполнения элементами управления окна приложения (например, текстовые надписи, кнопки, переключатели, вкладки), а также их расположения по области окна (менеджеры композиции). Для создания игр больше подходит компонент GDK (GTK Drawing Kit), который предоставляет низкоуровневые примитивы для рисования на поверхности и окна и GSK (GTK Scene Graph Kit) для группировки элементов окна. 

Для использования библиотеки GTK в Kotlin-Native можно применить связывание из https://gitlab.com/gtk-kt/gtk-kt. Подключим необходимые зависимости:

// основная библиотека
implementation("org.gtk-kt:gtk:1.0.0-alpha1")

// DSL-библиотека
implementation("org.gtk-kt:dsl:0.1.0-alpha0")

// поддержка корутин в Kotlin для обертки асинхронных вызовов GTK
implementation("org.gtk-kt:coroutines:0.1.0-alpha0")
implementation("org.gtk-kt:ktx:0.1.0-alpha0")

// обертки вокруг GDK, Cairo (поддержка графических операций) и Pango (поддержка вывода текста и шрифтов)
implementation("org.gtk-kt:cairo:0.1.0-alpha0")
implementation("org.gtk-kt:gdk-pixbuf:0.1.0-alpha0")
implementation("org.gtk-kt:pango:0.1.0-alpha0")

Для корректной установки соберем версии библиотек под свою аппаратную архитектуру и операционную систему. На MacOS нужно дополнительно установить:

brew install gtk4

на Debian/Ubuntu 

sudo apt install libgtk-4-dev libncurses5 gcc-multilib

или на Fedora

sudo dnf install gtk4-devel ncurses-compat-libs

Выполним клонирование исходных текстов gtk-kt и сборку в локальный maven-репозиторий:

git clone https://gitlab.com/gtk-kt/gtk-kt
cd gtk-kt
git checkout gtk-4
./gradlew publishToMavenLocal

Теперь создадим новый проект и попробуем нарисовать заставку для игры с использованием GDK. Добавим в начало списка repositories mavenLocal() и подключим перечисленные выше зависимости в kotlin.sourceSets:

        val nativeMain by getting {
            dependencies {
//...
            }
        }

И создадим пустое окно приложения:

import org.gtk.dsl.gio.onCreateUI
import org.gtk.gtk.widgets.DrawingArea
import org.gtk.dsl.gtk.*

fun main() {
    application("tech.dzolotov.samplegame") {
        onCreateUI {
            applicationWindow {
                title = "My Game"
                defaultSize = 512 x 512
                frame {
                }
            }.show()
        }
    }
}

Теперь сделаем содержанием frame изображение:

//...
					frame {
                    val image = Image()
                    image.setImage("logo.png", isResource = false)
                    child = image
					}
//...

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

tasks {
    val thePackageTask = register("package", Copy::class) {
        group = "package"
        description = "Copies the release exe and resources into one directory"

        from("$buildDir/processedResources/native/main") {
            include("**/*")
        }

        from("$buildDir/bin/native/releaseExecutable") {
            include("**/*")
        }

        into("$buildDir/packaged")
        includeEmptyDirs = false
        dependsOn("nativeProcessResources")
        dependsOn("assemble")
    }
    val zipTask = register<Zip>("packageToZip") {
        group = "package"
        description = "Copies the release exe and resources into one ZIP file."

        archiveFileName.set("packaged.zip")
        destinationDirectory.set(file("$buildDir/packagedZip"))

        from("$buildDir/packaged")

        dependsOn(thePackageTask)
    }
    named("build").get().dependsOn(zipTask.get())

    register<Exec>("runPackaged") {
        group = "package"
		 description = "Run packaged exe file"
        workingDir = File("$buildDir/packaged")
        commandLine("./${project.name}.kexe")
        dependsOn(thePackageTask)
    }
}

Теперь разместим изображение в ресурсы nativeMain (src/nativeMain/resources) и запустим наше приложение через ./gradlew runPackaged. После запуска приложения мы увидим окно с заголовком My Game и логотипом:

Добавим обработчик нажатие на кнопку мыши для перехода к основному экрану:

                    val click = GestureClick()
                    click.button = GDK_BUTTON_PRIMARY.toUInt()
                    click.addOnPressedCallback { nPress, x, y ->
                        removeController(click)
							//здесь мы будем заменять экран
                    }
                    addController(click)

После срабатывания контроллера необходимо отключить его от виджета, чтобы избежать повторный вызов. Заменим заставку на меню и сделаем его реализацию на основе сетки:

						click.addOnPressedCallback { nPress, x, y ->
                        removeController(click)
							child = menu(
                            {},
                            this@applicationWindow::destroy
                        )
                    }	

Меню будет содержать несколько элементов:

  • простая анимация (в нашем случае - вращающийся треугольник, но может быть любая другая);

  • заголовок (название приложение);

  • кнопки запуска игры и выхода.

Начнем с простого прототипа:

fun menu(onStart: () -> Unit, onQuit: () -> Unit): Widget {
    return grid {
        this.verticalAlign = Align.CENTER
        this.horizontalAlign = Align.CENTER


        val drawingArea = DrawingArea()
        drawingArea.sizeRequest = 64 x 64
        
        val label = Label("My Simple Game")

        button("Start Game", 0, 2, 1, 1) {
            addOnClickedCallback {
                onStart()
            }
        }
        button("Quit", 0, 3, 1, 1) {
            onClicked {
                onQuit()
            }
        }

        attach(drawingArea, 0, 0, 1, 1)
        attach(label, 0, 1, 1, 1)
	}
}

Далее добавим стилевое оформление к кнопкам и заголовку:

		val style = CssProvider()
  	    style.loadFromData(
     	 """
        .menuButton {
          color: blue;
          font-size: 22px;
          padding: 16px;
          margin: 16px;
        }
        
        .label {
          color: darkgreen;
          font-size: 40px;
          padding-bottom: 64px;
        }
    	""".trimIndent()
    	)

        val drawingArea = DrawingArea()
        drawingArea.sizeRequest = 64 x 64
		
 		val label = Label("My Simple Game").apply {
            this.styleContext.addProvider(style, GTK_STYLE_PROVIDER_PRIORITY_USER.toUInt())
            this.addCSSClass("label")
        }
        button("Start Game", 0, 2, 1, 1) {
            this.styleContext.addProvider(style, GTK_STYLE_PROVIDER_PRIORITY_USER.toUInt())
            this.addCSSClass("menuButton")
            addOnClickedCallback {
                onStart()
            }
        }
        button("Quit", 0, 3, 1, 1) {
            this.styleContext.addProvider(style, GTK_STYLE_PROVIDER_PRIORITY_USER.toUInt())
            this.addCSSClass("menuButton")
            onClicked {
                onQuit()
            }
        }
        attach(drawingArea, 0, 0, 1, 1)
        attach(label, 0, 1, 1, 1)

Также реализуем анимацию вращения треугольника, для этого переопределим drawingFunction для drawingArea и подсоединим обработчик TickCallback для контейнера:

		 var angle = 0.0

        val drawingArea = DrawingArea()
        drawingArea.sizeRequest = 64 x 64
        drawingArea.setOnDrawFunction { cairo, width, height ->
            val centerX = width / 2
            val centerY = height / 2
            val size = minOf(width / 2, height / 2)
            val point1 = centerX + size * cos(angle) to centerY + size * sin(angle)
            val point2 = centerX + size * cos(angle + 2 * PI / 3) to centerY + size * sin(angle + 2 * PI / 3)
            val point3 = centerX + size * cos(angle + 2 * 2 * PI / 3) to centerY + size * sin(angle + 2 * 2 * PI / 3)
            cairo.apply {
                setSourceRGB(0.8, 0.2, 0.2)
                moveTo(point1.first, point1.second)
                lineTo(point2.first, point2.second)
                lineTo(point3.first, point3.second)
                lineTo(point1.first, point1.second)
                stroke()
            }
        }

        addTickCallback {
            drawingArea.queueDraw()
            angle = it.frameCounter/60.0
            true
        }

Здесь возникнет небольшая проблема, поскольку в текущей реализации библиотеки gtk-kt очень мало поддерживаемых функций из cairo имеют kotlin-обертки, но это легко исправить через функции расширения:

fun Cairo.moveTo(x: Double, y: Double) {
    cairo_move_to(this.pointer, x, y)
}

fun Cairo.lineTo(x: Double, y: Double) {
    cairo_line_to(this.pointer, x, y)
}

fun Cairo.stroke() {
    cairo_stroke(this.pointer)
}

Аналогично может быть построено игровое поле с использованием графических примитивов Cairo. Но в настоящей игре также нужны более сложные визуальные эффекты - трехмерная графика, фоновая музыка и звуки. Для достижения этих целей мы можем использовать библиотеку SDL (Simple DirectMedia Layer), которая   представляет интерфейсы для работы с таймером, воспроизведением аудио и видео, считывания положения джойстика, game controller и управления haptic feedback. Пример использования SDL для декодирования аудио/видео можно найти в официальном примере от Jetbrains, где для поддержки кодеков используется библиотека ffmpeg. Подробно добавление музыки и звуков к нашей игре (как и работу с выделением памяти и указателями в Kotlin Native) мы рассмотрим во второй части этой статьи.

Исходные тексты приложения размещены на github: https://github.com/dzolotov/kotlin-game. 

Данный материал подготовлен в преддверии старта курса Kotlin Backend Developer. Professional. Узнать подробнее о курсе, а также получить запись бесплатного урока можно по ссылке ниже.

Комментарии (3)


  1. ScratchBoom
    07.12.2022 12:58
    +2

    Тоже балуюсь созданием игр на kotlin native

    Запилил движок - собирается под android, ios, js, windows, linux, jvm. (GL/WebGL графоний)

    Вот пара игр:
    https://yandex.ru/games/app/191647

    https://yandex.ru/games/app/191236?draft=true


  1. rutexd
    07.12.2022 13:35

    Kotlin native крутая вещь для любителей адекватного ооп. Спору нет. Jvm - > llvm = интересная комбинация.

    Однако возникают вопросы - например как интегрировать одно в другое - кастомный объект передать с си или наоборот. Как оно представляется в си. Есть ли вообще такая возможность. Как дела с перфомансом итд итд.

    На мои попытки влезть в котлин мне он показался слишком "скучным и пресным". Заточен среди хипстеров под мобилки. Что то другое - мало информации и слишком локальные комьюнити. Плюс банальное ограничение по дефолту закрытых классов - тоже отбило желание углубляться в язык.


    1. dmitriizolotov Автор
      07.12.2022 13:43

      Именно объект или структуру? Структуру можно определить в def-файле, для структур из h-файлов классы-обёртки создаются автоматически при генерации klib.

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

      Язык не только в мобилке используется :) (хотя там его действительно много, сильно много полезных фич есть по сравнению с java). На нём можно делать и веб-приложения (с нормальной типизацией как в TypeScript, но при этом с поддержкой многих других приятных дополнений, вроде DSL через лямбды и receiver), также можно делать и бэк. Сообщество тоже значительное (но конечно меньше java, просто из-за возраста языка) + очень хорошая документация и официальные примеры на многие кейсы (включая нативные приложения)