Скрипты
Скрипты Astral — это обычные файлы Kotlin с расширением .kts, которые
клиент компилирует прямо во время игры. Каждый скрипт становится полноценным
модулем: со своим именем, настройками, биндом и переключателем в меню.
Сохранил файл, написал .script reload — через полсекунды изменения уже в игре,
перезапуск не нужен.
Первый скрипт
Положи файл с расширением .kts в папку astralvisuals/scripts
внутри папки с игрой и напиши в чат .script reload.
Подсказки в IDE — по желанию. Скрипт работает и без них, но если хочется автодополнения и проверки типов, скачай SDK для IntelliJ IDEA: распакуй, открой папку в IDEA, и в Settings → Languages & Frameworks → Kotlin → Kotlin Scripting нажми Search Definitions. Внутри лежат все примеры из этой документации.
name("Hello Astral")
description("Печатает здоровье раз в секунду")
val period = slider("Каждые N тиков", 20, 1, 100)
var ticks = 0
onEnable { ticks = 0 }
on<TickEvent> {
if (!inGame) return@on
ticks++
if (ticks % period.intValue() != 0) return@on
print("hp ${player.health()} из ${player.maxHealth()}")
}
Импорты не нужны: всё API доступно напрямую. Скрипт появится в меню в разделе Scripts — там же будет ползунок «Каждые N тиков» и бинд.
Загружен ≠ включён. Файл, который скомпилировался, считается загруженным: он виден в меню со своими настройками. Но обработчики не работают, пока скрипт не включён. Перезагрузка файла сохраняет и состояние вкл/выкл, и бинд — а если в файле опечатка, предыдущая рабочая версия продолжает работать, и ошибка просто печатается в консоль.
Жизненный цикл
| Вызов | Что делает |
|---|---|
name(String) | Имя в меню. Вызывать один раз, вверху файла. |
description(String) | Подсказка при наведении. |
key(Int) | Бинд по умолчанию (код клавиши GLFW). Применяется только при первом появлении скрипта — свой бинд пользователя не перетирается. |
onEnable { } | При каждом включении. Место для сброса счётчиков. |
onDisable { } | При каждом выключении. Верни сюда всё, что менял: sneak, sprint. |
onUnload { } | Перед перезагрузкой или удалением файла. Последний шанс что-то сохранить. |
enabled / toggle() | Состояние скрипта из кода. |
print(Any) | Строка в консоль скриптов (не в чат). |
Команды
| Команда | Что делает |
|---|---|
.script | Статус движка и количество загруженных скриптов. |
.script list | Список скриптов и их состояние. |
.script reload | Перекомпилировать всё. С именем файла — только его. |
.script toggle <файл> | Включить или выключить скрипт. |
.script log [строк] | Консоль: ошибки компиляции, исключения, вывод print. |
.script folder | Путь к папке со скриптами. |
.script mcp [on|off] | Локальный MCP-сервер: даёт твоему ИИ-агенту писать и отлаживать скрипты прямо в запущенном клиенте. Строка подключения кликабельна — копируется в буфер. |
Всё про скрипты — в отдельной вкладке. Четвёртый кружок над
окном (значок >_) открывает вкладку «Скрипты»: сверху сами
скрипты с переключателями, биндами и настройками, снизу консоль — ошибки
компиляции, исключения и вывод print(). В левом списке категорий
скриптов больше нет: переключатель, который запускает скрипт, и вывод, который
объясняет, почему он упал, должны быть на экране одновременно.
Само меню тоже перетаскивается — схвати его за верхнюю полосу, где заголовки. Консоль — отдельное окно: кнопка «Консоль» в шапке вкладки открывает его, дальше его можно таскать за заголовок и тянуть за правый нижний угол. Три кнопки в заголовке: закрепить, очистить, закрыть. Закреплённое окно остаётся на экране при закрытом меню — ради этого оно и окно, а не панель: консоль читают, пока делают что-то ещё.
Каждая строка — время, уровень, файл, сообщение. Колесо прокручивает историю; пока не прокрутил, консоль держится на последней строке, чтобы ошибка не уезжала из вида, пока ты её читаешь.
Настройки
Объявляй настройки на верхнем уровне файла, а не внутри обработчика: тело скрипта выполняется один раз, и настройка, созданная в тике, будет пересоздаваться с потерей сохранённого значения.
val enabled = checkBox("Включить", true)
val delay = slider("Задержка", 5, 1, 20) // целые
val chance = slider("Шанс", 0.5, 0.0, 1.0, 0.05) // дробные, с шагом
val band = rangeSlider("Диапазон", 3.0, 6.0, 0.0, 10.0) // две ручки на одной шкале
val label = input("Подпись", "текст")
val colour = colorPicker("Цвет", 0xFF00FF88.toInt())
val mode = selectable("Режим", "Всегда", "На земле", "В воздухе")
val aim = hotkey("Прицелиться", -1) // клавиша, -1 = не назначена
val reset = button("Сбросить счётчик") { count = 0 }
delay.visibleWhen { enabled.on() }
enabled.onChange { print("теперь " + enabled.on()) }
| Метод | Возвращает |
|---|---|
.value() | Текущее значение. |
.value(x) | Задать значение из кода. |
.on() | Только у checkBox — Boolean без распаковки. |
.intValue() | Только у slider — округлённое целое. |
.selected("...") | Только у selectable — выбран ли вариант. |
.onChange { } | Реакция на изменение. |
.low() .high() | Только у rangeSlider — концы диапазона (.lowInt() / .highInt() — целые). |
.contains(x) | Только у rangeSlider — попадает ли число в диапазон, концы включительно. |
.random() | Только у rangeSlider — случайное число внутри диапазона. |
.matches(код) | Только у hotkey — совпадает ли клавиша. Пока клавиша не назначена, всегда false. |
.bound() | Только у hotkey — назначена ли клавиша. |
.onChange { } | Реакция на изменение. |
.visibleWhen { } | Показывать только при условии. |
rangeSlider — «от и до»
Две ручки на одной шкале вместо двух отдельных настроек, которые пользователь может выставить наоборот. Пара сама держит порядок: перетащил нижнюю ручку за верхнюю — концы меняются местами, а не ломаются.
val delay = rangeSlider("Задержка", 200.0, 450.0, 50.0, 1000.0, 10.0)
on<TickEvent> {
// ради этого он и нужен: «попадает ли число в выбранную полосу»
if (!delay.contains(sinceLastHit)) return@on
// и случайная величина внутри полосы — для разброса
nextDelay = delay.random()
}
У value() здесь нижний конец, а не пара: у всех остальных
настроек value() — обычное число или строка, и скрипт, который до
него дотянулся, не должен получать объект, с которым нечего делать. Явно —
low() и high().
hotkey и button
key(...) в начале файла включает и выключает весь скрипт.
hotkey(...) — это отдельная клавиша для действия внутри
уже работающего скрипта, и её можно переназначить в меню, не трогая код.
val aim = hotkey("Прицелиться", -1)
on<KeyEvent> {
if (it.pressed() && aim.matches(it.key())) rotations.lookAt(target)
}
button(...) значения не хранит — просто выполняет переданный код
по нажатию. Обработчик выполняется в клиентском потоке, так что внутри действуют
те же правила, что и в обработчике события: никаких длинных циклов.
var found = 0
val dump = button("Показать найденное") { chat.print("найдено: $found") }
Порядок объявления имеет значение. Тело скрипта выполняется сверху вниз, поэтому лямбда кнопки не может обратиться к переменной, объявленной ниже неё. Счётчик — сначала, кнопку — после.
Осторожно с именами. Метод проверки варианта называется
selected, а не is, потому что is —
ключевое слово Kotlin и вызвать такой метод было бы нельзя.
События
Подписка — on<ИмяСобытия> { }. Внутри доступен объект события;
выйти раньше времени — return@on.
on<AttackEvent> { e ->
if (e.target().effectiveHealth() > 20f) e.cancel()
}
// с приоритетом: FIRST видит событие раньше модулей клиента
on<TickEvent>(Priority.FIRST) { }
on<MoveEvent>(Priority.LAST, ignoreCancelled = true) { }
| Событие | Когда и что даёт |
|---|---|
TickEvent | 20 раз в секунду. Основная рабочая лошадка. |
PlayerTickEvent | В момент тика игрока — для движения и поворотов. |
AttackEvent | target(). Отменяемое. |
JumpEvent | Перед прыжком локального игрока. Отменяемое. |
MoveEvent | x() y() z() motion(), set(...), setY(...). |
MoveInputEvent | Зажатые клавиши движения. Читается и пишется: forward(), sprint(true), moving(). |
KeyEvent | key(), pressed(), released(), modifiers(), matches(код). |
MouseScrollEvent | up(), down(), vertical(). Отменяемое. |
EntitySpawnEvent | entity(). Приходит пачками при загрузке чанков. |
Render2DEvent | Кадр экрана: width(), height(), tickDelta(). |
Render3DEvent | Кадр мира: tickDelta(). |
CameraEvent | Камера уже поставлена, мир ещё не нарисован. yaw() и pitch() — только чтение; x(v) y(v) z(v) и position(Vec) двигают точку обзора, не двигая игрока. Кадр, а не тик. |
Мир и взаимодействие
Эти события приходят от игры, а не от таймера: чат, использование предметов, блоки, контейнеры, экраны. Большинство отменяемые — отмена происходит до отправки пакета, поэтому сервер о попытке не узнаёт.
| Событие | Когда и что даёт |
|---|---|
ChatReceiveEvent | Входящее сообщение до показа. message() уже без цветовых кодов, contains("текст") без учёта регистра. Отменяемое — строка не появится. |
ChatSendEvent | Исходящее сообщение. message() читает, message("новое") подменяет. Отменяемое. На команды клиента не срабатывает — они и так не уходят на сервер. |
UseItemEvent | Правый клик предметом. offHand(), mainHand(). Отменяемое. |
BlockInteractEvent | Правый клик по блоку: x() y() z(), side(), position(). Отменяемое. |
BlockAttackEvent | Первый удар по блоку — начало ломки. Отменять надо именно здесь: иначе клиент продолжит со следующего тика. |
BlockBreakEvent | Блок сломан. block() — полный id, isA("stone") — с необязательным namespace. Только уведомление. |
SlotClickEvent | Клик в открытом контейнере: slot(), button(), action(), isA("quick_move"). Отменяемое. У клика по списку предметов в креативе action() равен creative — ваниль ведёт его в обход обычного пути. |
ScreenOpenEvent | Экран собирается открыться. named("inventory"), title(), titleContains("Магазин"). Отменяемое. |
ScreenCloseEvent | Экран закрыт. Пустое имя экрана — вернулись в игру. |
WorldLoadEvent | Мир стал играбельным: вход на сервер, смена измерения, респавн. Место, где сбрасывать счётчики. |
// авто-ответ на приглашение в группу
on<ChatReceiveEvent> {
if (it.contains("пригласил вас в группу")) chat.send("/group accept")
}
// не давать сломать то, что стоит на защищённой территории
on<BlockAttackEvent> {
if (it.y() < 40) it.cancel()
}
// счётчик, который переживает смену мира правильно
var broken = 0
on<BlockBreakEvent> { if (it.isA("diamond_ore")) broken++ }
on<WorldLoadEvent> { broken = 0 }
Имена экранов — это устойчивые идентификаторы, а не имена классов:
inventory, creative, chest,
shulker, hopper, crafting,
furnace, brewing, anvil,
grindstone, stonecutter, loom,
cartography, enchanting, beacon,
merchant, horse, container —
нераспознанный контейнер, а также chat, pause,
death, title, disconnected,
sign, other. Пустая строка — вернулись в игру.
На сервере все меню — это один и тот же chest.
Магазин, список варпов и выбор кита различаются только заголовком, поэтому
отличай их через titleContains("Магазин"), а не через
named().
Проверка имени экрана — named(), не is().
is — ключевое слово Kotlin, метод с таким именем из скрипта не вызвать.
Та же причина, по которой у selectable проверка называется
selected().
Кадр — не тик. При 240 fps события рисования приходят в двенадцать
раз чаще, чем TickEvent. Решай в тике, рисуй в кадре.
player
Локальный игрок. Вне мира всё возвращает нули и false, а не падает — но начинать обработчик с if (!inGame) return@on всё равно правильно.
| Группа | Методы |
|---|---|
| Позиция | position() eyePosition() x() y() z() yaw() pitch() velocity() |
| Здоровье | health() maxHealth() absorption() effectiveHealth() armorPoints() |
| Сытость | food() saturation() airTicks() xpLevel() |
| Состояние | alive() onGround() sneaking() sprinting() inWater() onFire() flying() gliding() climbing() riding() creative() fallDistance() |
| Бой | attackCooldown() usingItem() itemUseTicks() hurtTicks() |
| Предметы | mainHandItem() offHandItem() |
| Эффекты | hasEffect("speed") effectAmplifier("speed") effectDuration("speed") |
Уровень эффекта считается с нуля, как в самой игре: «Скорость I»
это 0, «Скорость II» это 1. Отсутствие эффекта — -1.
world
val ground = world.block(player.position().add(0.0, -1.0, 0.0))
if (ground.isA("obsidian")) print("стоим на обсидиане")
val threat = world.nearestEntity(16.0) { it.isPlayer() && it.effectiveHealth() < 10f }
| Метод | Что даёт |
|---|---|
block(x, y, z) / block(Vec) | Блок: id() name() isA() air() solid() liquid() hardness() luminance() center() |
lightLevel(x, y, z) | Освещённость 0–15. |
entities() | Все загруженные сущности. |
players() | Игроки кроме тебя. |
entitiesNear(radius) | Сущности в радиусе, ближайшие первыми. |
nearestEntity(radius) { } | Ближайшая подходящая, либо null. |
nearestPlayer(radius) | Ближайший игрок. |
entityById(id) entityByName(name) | Поиск конкретной сущности. |
dimension() dayTimeTicks() raining() thundering() | Состояние мира. |
Сущность: name() id() typeId() position() distanceTo() health() effectiveHealth() alive() isPlayer() isSelf() sneaking() sprinting() invisible() width() height()
Не зови entities() в кадре. Он обходит все сущности мира;
в обработчике рисования это сотни раз в секунду. Собери список раз в тик и рисуй по нему.
inventory
Слоты нумеруются как в игре: 0–8 хотбар, 9–35 основная сетка, 36–39 броня, 40 вторая рука.
Поиск возвращает ScriptInventory.NOT_FOUND, то есть -1.
val slot = inventory.find { it.food() && it.nutrition() >= 4 }
if (slot in 0..8) control.selectSlot(slot)
val best = inventory.findBest { it.enchantmentLevel("sharpness").toDouble() }
| Метод | Что даёт |
|---|---|
held() offhand() item(slot) items() | Предметы. |
find("id") find { } findInHotbar("id") findBest { } | Поиск слота. |
count("id") full() freeSlots() selected() | Состояние. |
armor("helmet") naked() | Броня. |
Предмет: empty() id() name() isA() displayName() count() damage() maxDamage() durability() durabilityFraction() enchanted() enchantmentLevel("sharpness") food() nutrition() saturation()
Действия
rotations.lookAt(target)
if (rotations.angleTo(target.position()) < 30f) interaction.attack(target)
| Объект | Методы |
|---|---|
interaction | attack(entity) attackCrosshair() swing() useItem() stopUsingItem() swapHands() |
control | jump() sneaking(Boolean) sprinting(Boolean) selectSlot(0..8) usingSlot(слот) { } |
rotations | current() lookAt(Vec) lookAt(entity) apply(rot, "high") angleTo(Vec) towards(Vec) settled() |
chat | print() и overlay() — локально; send() — в чат серверу. |
usingSlot — временно взять другой предмет
Выбирает слот, выполняет блок, возвращает прежний слот обратно. Возврат
стоит в finally, поэтому даже если внутри вылетит исключение,
игрок останется с тем, что держал. Без этого один кривой скрипт молча забрал
бы у пользователя оружие посреди боя и не вернул.
val pearl = inventory.findInHotbar("ender_pearl")
if (pearl != -1) control.usingSlot(pearl) { interaction.useItem() }
Это не «тихая» смена в смысле пакетов: сервер видит смену слота, действие и смену обратно — ровно то же, что увидел бы, сделай это игрок руками. Так задумано: вариант, прячущий подмену от сервера, — именно то, что ищут анти-читы, и он не стоит той поддержки, которой обойдётся. Неверный слот или слот, который уже выбран, просто выполняют блок без подмены.
Поворот живёт один тик. Чтобы продолжать смотреть в точку, проси об этом каждый тик. Запросы проходят через движок поворотов клиента, поэтому они плавные и разрешаются по приоритету, а не дерутся друг с другом.
И помни про attackCooldown(): удар до полной зарядки бьёт слабее.
Sneak и sprint — состояния, а не действия. То, что включил, надо выключить самому, когда условие перестало выполняться:
// правильно — обе стороны, каждый тик
val нужно = player.health() < 6f
if (нужно != player.sneaking()) control.sneaking(нужно)
// неправильно — присядет и останется сидеть навсегда
if (player.health() < 6f) control.sneaking(true)
Клиент подстрахует: всё, что скрипт зажал, отпускается при его выключении или перезагрузке. Но пока скрипт работает, следить за состоянием — твоя задача.
Рисование
Только внутри on<Render2DEvent> — в другом месте вызовы просто
ничего не нарисуют. Цвета — упакованные ARGB-числа, ровно то, что отдаёт
colorPicker.
on<Render2DEvent> { e ->
if (!inGame) return@on
val w = 140f
val x = e.width() - w - 10f
render.blur(x, 10f, w, 46f, 6f, 6f, render.rgba(20, 20, 20, 140))
val hp = player.health() / player.maxHealth()
val bar = render.mix(render.rgb(220, 60, 60), render.rgb(0, 255, 136), hp)
render.roundedRect(x + 8f, 20f, (w - 16f) * hp, 6f, 3f, bar)
render.styledText(player.name(), x + 8f, 32f, 8f, render.rgb(255, 255, 255), "semibold")
}
| Метод | Что рисует |
|---|---|
rect roundedRect outline | Прямоугольники и рамки. |
gradientVertical gradientHorizontal | Градиенты. |
blur shadow | Размытие фона и мягкая тень. |
text textCentered textRight styledText | Текст. Начертания: regular medium semibold bold. |
textWidth textHeight | Замер до отрисовки. |
rgb rgba withAlpha mix | Цвета. |
Координаты — в масштабируемом пространстве интерфейса игры. Привязывайся к
e.width() и e.height(), иначе при другом масштабе GUI
всё уедет. blur читает кадровый буфер: одна панель — нормально,
размытие на каждую сущность — нет.
Утилиты
| Что | Как |
|---|---|
inGame | Есть ли игрок в загруженном мире. |
whenInGame { } | То же условие, но без раннего выхода. |
timer() | Секундомер: elapsed() passed(ms) tryReset(ms) reset(). Держи в val на верхнем уровне. |
afterTicks(n) { } | Выполнить один раз через n тиков. |
notify(title, text, "warn") | Всплывающее уведомление. |
storage | Своё хранилище: put/getInt/getString/getBoolean/getDouble/has/remove/keys/save. Переживает перезагрузку и перезапуск. |
Vec | add subtract multiply length normalize distanceTo squaredDistanceTo blockX/Y/Z |
Не вызывай storage.save() в тике — это запись на диск
двадцать раз в секунду. Клиент сам сохраняет при выгрузке скрипта.
Ограничения
Скрипты выполняются в песочнице. Разрешено всё нужное: стандартная библиотека
Kotlin, коллекции, строки, математика, Random, java.time,
java.math — и, конечно, всё API Astral.
Запрещено и не заработает:
- файлы и папки — вместо этого
storage; - сеть;
- потоки и таймеры — вместо этого
afterTicksиtimer(); - рефлексия и загрузка классов;
- запуск процессов,
System, доступ к внутренностям клиента.
Обработчик, упавший пять раз подряд, выключает скрипт: иначе ошибка в тике писала бы в консоль двадцать строк в секунду. Одно успешное выполнение сбрасывает счётчик. Медленные обработчики отмечаются предупреждением в консоли.
Бесконечный цикл повесит игру. Безопасно остановить уже выполняющийся код нельзя, и песочница тут не поможет. Если скрипт перебирает что-то большое — ограничивай сам.
Примеры
Автоматическая еда на низком здоровье
name("Auto Eat")
val threshold = slider("Здоровье", 8.0, 1.0, 20.0, 0.5)
val cooldown = timer()
on<TickEvent> {
if (!inGame) return@on
if (player.health() + player.absorption() > threshold.value()) return@on
if (player.usingItem() || !cooldown.passed(1500)) return@on
val slot = inventory.find { it.food() && it.nutrition() >= 4 }
if (slot !in 0..8) return@on
control.selectSlot(slot)
interaction.useItem()
cooldown.reset()
}
Счётчик убийств, который переживает перезапуск
name("Kill Counter")
var kills = storage.getInt("kills", 0)
on<AttackEvent> { e ->
if (e.target().effectiveHealth() > 2f) return@on
kills++
storage.put("kills", kills)
notify("Kill Counter", "всего: $kills", "success")
}
onUnload { storage.put("kills", kills) }
Предупреждение о слабом противнике рядом
name("Low Health Warning")
val radius = slider("Радиус", 16, 4, 64)
var last = ""
on<TickEvent> {
if (!inGame) return@on
val weak = world.nearestEntity(radius.value().toDouble()) {
it.isPlayer() && it.alive() && it.effectiveHealth() < 6f
} ?: return@on
if (weak.name() == last) return@on
last = weak.name()
chat.overlay("${weak.name()} — ${weak.effectiveHealth().toInt()} hp")
}