Документация
Kuroko 1.1.0-dev
JVM-язык для Minecraft-плагинов: аннотации вместо бойлерплейта, auto-import Paper API, null-safety и понятные ошибки. Всё ниже проверено на тест-корпусе компилятора.
Установка
Kuroko компилирует в JVM-байткод, поэтому единственное требование — установленная Java.
Требования
- JDK 17 или новее (рекомендуется 21) — проверьте командой
java -version - Gradle 8.x — wrapper поставляется вместе с проектами, отдельная установка не нужна
Создание проекта
kuroko init MyPlugin
cd MyPluginКоманда создаст каркас: build.gradle.kts с плагином io.sylnora.kuroko, конфиг kuroko.toml и стартовый шаблон src/main/kuroko/MyPlugin.kr.
Сборка и тестовый сервер
./gradlew kurokoBuild # .jar в build/libs/
./gradlew kurokoRun # Paper в run/ с вашим плагиномГотовый .jar появляется в build/libs/. Задача kurokoRun скачает Paper, положит плагин в plugins/ и запустит сервер.
Первый плагин за 5 минут
Сделаем плагин, который приветствует игрока при входе и отвечает на команду /hello. Полный код — 14 значимых строк.
1. Пишем код
Откройте src/main/kuroko/HelloPlugin.kr и замените содержимое:
2. Собираем и запускаем
./gradlew kurokoBuild
./gradlew kurokoRunЗайдите на сервер и проверьте: при входе появляется золотое приветствие, команда /hello отвечает зелёным сообщением.
3. Что произошло под капотом
- @Plugin → класс наследует
JavaPlugin,plugin.ymlсгенерирован из аннотации - @On → класс зарегистрирован как
Listener, метод получил@EventHandler - @Command → создан
CommandExecutor, команда прописана вplugin.yml - msg("&6…") → цветовые коды переведены автоматически
- Player, PlayerJoinEvent → резолвнуты через auto-import, ни одного
use
Структура проекта
MyPlugin/
├── build.gradle.kts ← плагин io.sylnora.kuroko
├── kuroko.toml ← конфиг языка и MC-платформы
├── settings.gradle.kts
└── src/
├── main/
│ ├── kuroko/
│ │ └── MyPlugin.kr
│ └── resources/
│ └── config.yml ← опционально
└── test/
└── kuroko/
└── MyPluginTest.kr
└── run/ ← тестовый сервер (gitignored)Каждая команда компилятора знает про эту структуру: kurokoc compile src/ собирает все .kr-файлы, kurokoc check src/ проверяет без генерации, kurokoc run Main.kr исполняет скрипт.
Переменные и типы
val — неизменяемая переменная, var — изменяемая. Тип выводится автоматически; явно указывайте его, когда нужно.
val name = "Steve" // String, выводится сам
var health = 20 // Int
var ratio: Float = 0.5 // явный тип
say("Игрок: $name, HP: $health") // строковые шаблоны
say("Урон: ${health * 2}")Числа с единицами измерения
Специально для Minecraft: единицы — часть языка, компилятор переводит их в тики и не даёт перепутать дистанцию со скоростью.
val speed = 0.3 blocks_per_tick
val delay = 100 ms
val reload = 5 seconds
val range = 10 blocks| Тип | Литерал | Для чего |
|---|---|---|
| Int | 20 | счётчики, количество |
| Double | 0.5 | координаты, урон в здоровье |
| Boolean | true / false | флаги |
| String | "текст" | сообщения, имена, шаблоны $x |
| T? | null | nullable-значения — см. null-safety |
Функции
Однострочные функции — без фигурных скобок и return. Умолчания параметров работают как в Kotlin. Точка входа скрипта — fun main(), класс-обёртка не нужна.
fun main() {
say(square(4)) // 16
say(greet("Steve")) // Привет, Steve!
spawn(world) // умолчания y = 64.0
}
fun square(x: Int) = x * x
fun greet(name: String) = "Привет, $name!"
fun spawn(world: World, x: Double = 0.0, y: Double = 64.0, z: Double = 0.0) {
// ...
}Расширяющие функции
Добавляйте методы к чужим классам — например, к Player из Paper API.
fun Player.heal(amount: Int) {
health = min(health + amount, 20)
}
// вызов как обычный метод:
player.heal(10)Лямбды
val doubled = listOf(1, 2, 3).map { it * 2 }
val evens = listOf(1, 2, 3, 4).filter { it % 2 == 0 }
every(1 second) onlyIf { players.size > 0 } {
players.forEach { p -> p.msg("&7Тик") }
}Классы
Классы выглядят как в Kotlin: первичный конструктор в заголовке, свойства — val/var. data class даёт equals, hashCode, toString и copy бесплатно. object — синглтон вместо static.
class Player(val name: String, var health: Int = 20) {
fun isAlive() = health > 0
fun takeDamage(amount: Int) {
health -= amount
if (!isAlive()) say("$name погиб!")
}
}
data class Item(val id: String, val name: String, val damage: Int)
object Config {
val version = "1.0.0"
val debug = false
}
class Boss(name: String) : Player(name, health = 200) {
override fun takeDamage(amount: Int) {
super.takeDamage(amount / 2) // боссы получают половину урона
}
}new — просто Item("sword", "Меч", 7). Нет public/private на каждом методе — умолчание public. Нет ;.Управление потоком
if, цикл for … in по коллекциям и диапазонам, while и when вместо switch. when работает и как выражение.
// диапазоны
for i in 1..10 {
say(i)
}
// коллекции
for item in list {
say(item)
}
// пока
while health > 0 {
health--
}
// when — как switch, но умнее
when material {
DIAMOND -> say("Алмаз!")
GOLD -> say("Золото!")
is Sword -> say("Меч!")
else -> say("Что-то другое")
}
// when как выражение
val price = when material {
DIAMOND -> 100
GOLD -> 50
else -> 1
}Коллекции
Списки, множества и словари создаются литералами — без listOf() и mapOf().
val list = [1, 2, 3, 4, 5] // список
val map = {"key": "value", "hp": 20} // словарь
val set = {1, 2, 3} // фигурные без пар — множество
// функциональный стиль
val names = players.map { it.name }.filter { it.startsWith("A") }
val top = Players.where { kills > 10 }.orderBy { kills }.limit(10).find()| Операция | Пример | Что делает |
|---|---|---|
| map | list.map { it * 2 } | преобразует каждый элемент |
| filter | list.filter { it > 2 } | оставляет подходящие |
| forEach | list.forEach { say(it) } | обходит с действием |
| where / orderBy / limit | table-запросы | цепочки запросов к таблицам |
Null-safety
Тип без ? не может быть null — это проверяется компилятором с кодом KR001. Сервисные вызовы — через ?., значение по умолчанию — через ?: (элвис).
var player: Player? = null // может отсутствовать
player?.sendMessage("Привет") // безопасный вызов
val name = player?.name ?: "Неизвестный" // элвис
// в командах это избавляет от проверок:
val world = server.getWorld(home.world)
?: return player.msg("&cМир не найден!")Плагин и жизненный цикл
Плагин — класс с аннотацией @Plugin, наследующий KPlugin. Блоки on load / on unload превращаются в onEnable / onDisable.
@Plugin(
name = "HomePlugin",
version = "1.0",
description = "Система домов для игроков",
authors = ["Sylnora"]
)
class HomePlugin : KPlugin() {
on load {
Homes.createTable()
log("HomePlugin загружен. Дома готовы.")
}
on unload {
log("HomePlugin выгружен.")
}
}Из аннотации компилятор собирает plugin.yml: name, version, main, api-version, authors и все команды с правами. Манифест не может разъехаться с кодом — он генерируется из него.
Команды
Команда — аннотированная функция. Права, кулдаун и usage — соседние аннотации. Аргументы приходят массивом, отправитель типизируется параметром.
@Command("sethome", description = "Установить точку дома", usage = "/sethome [название]")
@Permission("homeplugin.sethome")
@Cooldown(3 seconds, message = "&cПодожди ещё {remaining} сек!")
fun cmdSetHome(player: Player, args: Array) {
val name = args.getOrElse(0) { "home" }
val loc = player.location
Homes.set(player = player.uniqueId, name = name, world = loc.world.name,
x = loc.x, y = loc.y, z = loc.z)
player.msg("&aДом '&e$name&a' установлен!")
}
@Command("home", description = "Телепортироваться домой")
@Permission("homeplugin.home")
@Cooldown(5 seconds)
fun cmdHome(player: Player, args: Array) {
val name = args.getOrElse(0) { "home" }
val home = Homes.find(player.uniqueId, name)
?: return player.msg("&cДом '&e$name&c' не найден!")
player.tp(Location(server.getWorld(home.world), home.x, home.y, home.z))
player.msg("&aТелепорт к дому '&e$name&a'!")
} - @Permission — проверка права и запись в plugin.yml permissions
- @Cooldown — пер-игроковый кулдаун, плейсхолдер {remaining} в сообщении
- player.msg() — отправка сообщения с авто-переводом &-цветов
События
Событие — функция с @On, тип события выводится из сигнатуры. Регистрация слушателя генерируется автоматически.
@On
fun onJoin(event: PlayerJoinEvent) {
event.player.msg("&6Добро пожаловать, &e${event.player.name}&6!")
event.joinMessage = null // убрать системное сообщение
}
@On
fun onBreak(event: BlockBreakEvent) {
if (event.block.type == DIAMOND_ORE) {
log("${event.player.name} добыл алмаз")
}
}Поддерживаются стандартные события Paper: PlayerJoinEvent, PlayerQuitEvent, PlayerMoveEvent, BlockBreakEvent, BlockPlaceEvent, EntityDamageEvent, InventoryClickEvent и остальные — через auto-import.
Задачи и async
Три конструкции закрывают планировщик Bukkit: every — периодическая задача, after — отложенная, async — вне главного потока. Единицы времени пересчитываются в тики компилятором.
every(20 ticks) { // каждую секунду
players.forEach { p ->
if (p.health < 5) p.msg("&cОпасно! Мало HP!")
}
}
after(5 seconds) { // через 5 секунд
log("Прошло 5 секунд")
}
async { // не блокируем главный поток
val data = fetchFromDatabase()
player.msg("Загружено: $data")
}
// с условием
every(1 second) onlyIf { players.size > 0 } {
log("кто-то онлайн")
}Таблицы и GUI
table {} — декларативная база данных
Таблица объявляется прямо в плагине. Компилятор создаёт SQLite-схему и DAO-методы. Ни одной строки SQL.
table Homes {
player: UUID primary
name: String primary
world: String
x: Double; y: Double; z: Double
}
on load { Homes.createTable() }
// CRUD без SQL:
Homes.set(player.uniqueId, "home", loc)
val home = Homes.find(player.uniqueId, "home")
val list = Homes.where { player == player.uniqueId }.find()
Homes.delete(player.uniqueId, "home")Из этого объявления генерируется DDL:
GUI — меню инвентаря DSL-ом
@Command("menu")
fun cmdMenu(player: Player) {
val gui = GUI("&6Меню", rows = 3) {
slot(1, 1) {
item = DIAMOND
name = "&bАлмаз"
onClick { p ->
p.giveItem(DIAMOND, 1)
p.msg("&aПолучен алмаз!")
}
}
slot(2, 2) {
item = BARRIER
name = "&cЗакрыть"
onClick { it.closeInventory() }
}
}
player.openInventory(gui)
}config {} и smartItem
config("settings") {
"max_players" default 100
"prefix" default "&6[MyPlugin] "
"debug" default false
}
val maxPlayers = config["max_players"].int
val sword = smartItem(DIAMOND_SWORD) {
name = "&6Экскалибур"
lore = ["&7Легендарный меч", "&8Урон: &c+50"]
undroppable = true
onRightClick { p -> p.msg("&bМеч дрожит...") }
}Каталог ошибок
Каждая ошибка компилятора имеет код KR-класса, сообщение на русском и подсказку. kurokoc explain KR001 печатает полное объяснение.
| Код | Значение | Как чинить |
|---|---|---|
| KR001 | Null присваивается non-nullable типу | Объяви тип с ? (var hp: Int?) или присвой значение; для строк — toIntOrNull() ?: 0 |
| KR002 | Имя не найдено (unresolved reference) | Тип не в auto-import и не объявлен локально — проверь опечатку или добавь use |
| KR003 | Нет точки входа | Файл скрипта без fun main() и без @Plugin-класса |
| KRS01/02 | Синтаксис: непарная скобка, незакрытая строка | Компилятор укажет строку открытой скобки — проверь баланс |
Пример сообщения
Плохо:
var health: Int = nullХорошо:
var health: Int? = null // значение может отсутствовать
var health2: Int = 20 // или просто присвой числоKuroko против Java
Замеры по каноническим реализациям HomePlugin в монорепозитории: examples/home-plugin/, методика — в LOC_COMPARISON.md.
| Язык | Файл | Всего строк | Значимых | Импортов |
|---|---|---|---|---|
| Java | HomePlugin.java | ~210 | ~175 | 15 |
| Kotlin | HomePlugin.kt | ~160 | ~130 | 10 |
| Kuroko | HomePlugin.kr | ~85 | ~55 | 0 |
Что именно исчезает
- Java: отдельные CommandExecutor-классы, ручные проверки прав в каждой команде, многословный YAML-код, явные registerEvents / setExecutor, plugin.yml отдельным файлом
- Kotlin: ergonomics лучше, но всё то же самое остаётся
- Kuroko: команда + права + кулдаун = одна аннотированная функция; хранилище = table {}; жизненный цикл = on load / on unload; ноль импортов Paper API
HelloPlugin в цифрах: Java ~48–55 строк, Kotlin ~28–35, Kuroko — 14. plugin.yml у Java и Kotlin пишется вручную, у Kuroko генерируется.
kuroko.toml
Конфиг проекта объявляет платформу Minecraft — от неё зависит auto-import и цели сборки.
[language]
version = "1.0"
target-jvm = 17
[minecraft]
api = "paper" # paper / spigot / purpur / fabric / forge / neoforge
version = "1.21.4"
auto-import = true
[compiler]
warnings-as-errors = false
language-errors = "ru" # язык сообщений: ru / en
incremental = true
optimize = true
[runtime]
debug = falseФлаги компилятора
kurokoc compile src/ # компиляция всех .kr
kurokoc check src/ # только проверка
kurokoc run Main.kr # запуск скрипта
kurokoc explain KR001 # объяснение ошибки
kurokoc build # полная сборка через GradleПлатформы
| Платформа | Статус |
|---|---|
| Paper 1.21.x | основная |
| Purpur / Leaf | тот же байткод, что и Paper |
| Spigot 1.16+ | best effort |
| Folia | планировщик отличается — проверки в рантайме |
| Fabric / Forge / NeoForge | адаптер — после Beta |
Текущий статус: 1.1.0-dev, design-stable developer preview. Политика совместимости 1.x — только аддитивные изменения. Полная дорожная карта — на главной.