Возможность компиляции 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
}
}
Если необходимо подключить сторонние библиотеки, необходимо выполнять ряд подготовительных операций
На основе 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"))
}
}
}
}
Импортировать библиотеки и использовать методы из kotlinx.cinterop для работы со структурами данных, указателями, выделения и освобождения памяти, строками.
Подключить 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)
rutexd
07.12.2022 13:35Kotlin native крутая вещь для любителей адекватного ооп. Спору нет. Jvm - > llvm = интересная комбинация.
Однако возникают вопросы - например как интегрировать одно в другое - кастомный объект передать с си или наоборот. Как оно представляется в си. Есть ли вообще такая возможность. Как дела с перфомансом итд итд.
На мои попытки влезть в котлин мне он показался слишком "скучным и пресным". Заточен среди хипстеров под мобилки. Что то другое - мало информации и слишком локальные комьюнити. Плюс банальное ограничение по дефолту закрытых классов - тоже отбило желание углубляться в язык.
dmitriizolotov Автор
07.12.2022 13:43Именно объект или структуру? Структуру можно определить в def-файле, для структур из h-файлов классы-обёртки создаются автоматически при генерации klib.
Относительно производительности - ну тут любой язык будет немножко терять из-за своего рантайма, но в целом (поскольку код компилируется в исполняемый) она достаточно высокая (по крайней мере по сравнению с JVM Target).
Язык не только в мобилке используется :) (хотя там его действительно много, сильно много полезных фич есть по сравнению с java). На нём можно делать и веб-приложения (с нормальной типизацией как в TypeScript, но при этом с поддержкой многих других приятных дополнений, вроде DSL через лямбды и receiver), также можно делать и бэк. Сообщество тоже значительное (но конечно меньше java, просто из-за возраста языка) + очень хорошая документация и официальные примеры на многие кейсы (включая нативные приложения)
ScratchBoom
Тоже балуюсь созданием игр на kotlin native
Запилил движок - собирается под android, ios, js, windows, linux, jvm. (GL/WebGL графоний)
Вот пара игр:
https://yandex.ru/games/app/191647
https://yandex.ru/games/app/191236?draft=true