- 54
- 28
UltraLight for Moonloader
HTML / CSS / JS интерфейсы прямо в GTA SA и SA-MP, управляемые из Lua
HTML / CSS / JS интерфейсы прямо в GTA SA и SA-MP, управляемые из Lua
Что это
Библиотека для MoonLoader, которая позволяет делать окна интерфейса на обычном HTML, CSS и JavaScript и показывать их поверх игры. Вместо ручной отрисовки через render/imgui вы пишете страницу, а из Lua только отправляете в неё данные и получаете события от кнопок.
Под капотом работает движок Ultralight (лёгкий WebKit-рендерер для игр и приложений).
Как это устроено
GTA SA, 32-разрядная программа, а Ultralight 1.4 распространяется только в 64-разрядной сборке. Поэтому работа разделена на два процесса:
Код:
gta_sa.exe (x86) ul_helper.exe (x64)
├─ MoonLoader: ваш Lua-скрипт └─ Ultralight 1.4
│ └─ lib/ultralight.lua (FFI) рендерит HTML-страницу
└─ ultralight_lua.asi ◄─── общая память ───►
• запускает ul_helper.exe команды: load_url, eval, мышь, клавиши
• рисует кадр поверх игры (D3D9) ◄── кадры (картинка) и сообщения JS → Lua
- ultralight_lua.asi (32 бит, без Ultralight внутри). При старте скрипта запускает хелпер, получает от него готовые кадры и рисует их поверх игры через хук EndScene. Ввод и команды передаёт обратно хелперу.
- ul_helper.exe (64 бит). Содержит Ultralight, загружает страницу, исполняет JS, рендерит на CPU и кладёт кадры в общую память. Завершается вместе с игрой.
- lib/ultralight.lua. Lua-обёртка через FFI. Всё, что вы используете в своих скриптах, находится здесь.
- Общая память. Два буфера кадра и две очереди сообщений (в хелпер и из хелпера).
Путь данных Lua → страница:
ul.call / ul.push превращаются в JS-вызов, который выполняется в странице.Путь данных страница → Lua: страница вызывает
lua('имя', данные), это уходит в очередь, а Lua забирает событие в ul.process() и вызывает ваш обработчик, зарегистрированный через ul.on.Перезагрузка Lua-скрипта (Ctrl+R) безопасна: хелпер уже запущен и просто переиспользуется.
Требования
- MoonLoader, SAMPFUNCS, ASI Loader
Установка
- Скопируйте содержимое
архивав корень GTA:
Код:
GTA/
├─ ultralight_lua.asi
├─ ultralight/
│ ├─ ul_helper.exe
│ ├─ Ultralight.dll, UltralightCore.dll, WebCore.dll, AppCore.dll
│ └─ resources/ (cacert.pem, icudt67l.dat)
└─ moonloader/
└─ lib/ultralight.lua
HTML создаётся из Lua-скрипта
Отдельные .html файлы в архиве не нужны. Страница хранится обычной строкой внутри Lua-скрипта, а библиотека сама превращает её в файл:
Lua:
local HTML = [==[
<html><head><meta charset="utf-8"> ... </head><body> ... </body></html>
]==]
local url = ul.create_page('mypage', HTML) -- создаёт moonloader/resource/ul/mypage.html
ul.load_url(url)
- Файл записывается в
moonloader/resource/ul/<имя>.htmlавтоматически при старте скрипта. Если содержимое не изменилось, диск не трогается. - Весь интерфейс лежит в одном .lua файле. Его удобно распространять, и ничего не нужно раскладывать по папкам.
- Если в вашей странице нет своей функции
lua(), библиотека сама добавит служебный JS (связь со Lua и отслеживание фокуса на полях ввода). - Хотите вообще без файла? Используйте
ul.load_html(HTML). Но при загрузке из файла работают относительные пути к картинкам и шрифтам. - Сохраняйте скрипт в UTF-8: текст страницы пишется на диск как есть. Если скрипт в cp1251, передавайте
u8(HTML). - Если страница лежит в отдельном файле, загружайте её так:
ul.load_url('file:///moonloader/resource/мои_файлы/page.html'). Путь считается от папки GTA.
Быстрый старт
Минимальный скрипт: окно с кнопкой, по нажатию в чат приходит сообщение.
Lua:
script_name('ul_hello')
local ul = require 'ultralight'
local encoding = require 'encoding'
encoding.default = 'CP1251'
local u8 = encoding.UTF8
local HTML = [==[
<html><head><meta charset="utf-8"><style>
body { margin:0; background:transparent; font-family:Arial; color:#fff; }
.card { margin:8px; padding:16px; border-radius:12px; background:rgba(20,24,32,.9); }
button { padding:8px 14px; border:0; border-radius:6px; background:#3d7bdc; color:#fff; cursor:pointer; }
</style></head><body>
<div class="card">
<h3 id="title">Привет из HTML</h3>
<button onclick="lua('hello', { text: 'Кнопку нажали' })">Нажми</button>
</div>
<script>function setTitle(s){ document.getElementById('title').textContent = s; }</script>
</body></html>
]==]
local shown = true
function main()
while not isSampAvailable() do wait(100) end
ul.init {
x = 100, y = 100, w = 400, h = 200,
draggable = true, -- окно можно двигать мышью
cursor = true, -- библиотека сама включает курсор
}
ul.load_url((ul.create_page('hello', HTML)))
ul.on('hello', function(d) -- событие из страницы
sampAddChatMessage('[UI] ' .. u8:decode(d.text), -1)
end)
sampRegisterChatCommand('hello', function()
shown = not shown
ul.set_visible(shown) -- курсор включится и выключится сам
end)
while true do
wait(0)
ul.process() -- ОБЯЗАТЕЛЬНО каждый кадр
end
end
function onWindowMessage(msg, wparam, lparam) -- ввод мыши и клавиатуры в окно
if ul.window_message(msg, wparam, lparam) then
consumeWindowMessage(true, false)
end
end
function onScriptTerminate(s)
if s == thisScript() then ul.shutdown() end
end
Функцию
lua() в странице объявлять не нужно: библиотека добавляет её сама (если вы не объявили свою). Курсор в примере включается флагом cursor = true, а окно двигается за любое место без кнопок.Флаги окна: перемещение, размер, курсор
Всё настраивается флагами прямо в
ul.init{...}, дополнительного кода не нужно:
Lua:
ul.init {
x = 100, y = 100, w = 520, h = 400,
draggable = true, -- окно можно двигать мышью (по умолчанию false)
resizable = true, -- размер меняется за край или угол (по умолчанию false)
cursor = true, -- курсор включается вместе с окном (по умолчанию false)
lock_controls = true, -- при cursor=true блокировать управление (по умолчанию true)
min_w = 300, min_h = 200, -- границы размера при изменении мышью (по умолчанию 100 x 60)
max_w = 1280, max_h = 720, -- (по умолчанию 1920 x 1080)
resize_border = 8, -- толщина зоны захвата по краям, px (по умолчанию 8)
}
- Окно тащится левой кнопкой мыши за любое место, где нет кнопок и полей ввода. Нажатие на кнопку, ссылку,
<input>,<textarea>или<select>окно не двигает, они работают как обычно. - Чтобы тащить только за шапку, пометьте её атрибутом
data-ul-drag. Как только в странице есть хотя бы один такой элемент, остальные места окна перестают его двигать. - Чтобы исключить область (список с прокруткой, ползунок, карту), пометьте её атрибутом
data-ul-nodrag. Он действует и на вложенные элементы. - Окно не выходит за границы экрана.
- Служебный JS для перетаскивания библиотека добавляет в страницу сама при
ul.create_page. Если вы загружаете страницу другим способом, вызовитеlua('__drag')наmousedownвашей «ручки».
HTML:
<div class="card">
<div class="head" data-ul-drag> <!-- тащим только за эту полосу -->
Заголовок окна
<button onclick="lua('close')">✕</button> <!-- кнопка в шапке работает как кнопка -->
</div>
<div class="body" data-ul-nodrag> ... </div> <!-- здесь окно не двигается -->
</div>
- Размер меняется, если взяться мышью за любой край или угол окна (зона захвата по краям шириной
resize_border, 8 пикселей по умолчанию) и потянуть. Левый и верхний края тоже работают, окно при этом сдвигается. - Размер ограничен значениями
min_w, min_h, max_w, max_h, а также границами экрана. Максимум самой библиотеки: 1920×1080. - Нажатия в зоне захвата идут библиотеке, а не странице. Не размещайте кнопки вплотную к краю окна (оставьте отступ больше
resize_border). - Форма курсора не меняется (из Lua её не поменять), поэтому нарисуйте в углу окна небольшую «ручку» (
◢), чтобы пользователь понимал, что размер можно менять. - Страница должна подстраиваться под размер: используйте проценты,
flex,grid,vw/vh, а не фиксированные пиксели. Во время изменения размер применяется не чаще ~30 раз в секунду.
HTML:
<style>
html, body { height:100%; margin:0; }
.card { height:100%; display:flex; flex-direction:column; box-sizing:border-box; }
.body { flex:1; overflow:auto; } /* растёт вместе с окном */
.grip { position:absolute; right:10px; bottom:6px; opacity:.4; }
</style>
<div class="card"><div class="body">...</div><div class="grip">◢</div></div>
Флаг
cursor = true поручает показ курсора библиотеке: он включается при показе окна и выключается при скрытии окна или ul.shutdown(). Под капотом это функция showCursor(show, lockControls):cursor = true, lock_controls = true→showCursor(true, true): курсор виден, управление персонажем и камерой заблокировано. Для меню, инвентарей, диалогов.cursor = true, lock_controls = false→showCursor(true, false): курсор виден, персонаж и камера управляются. Для кликабельных HUD, когда нужно ходить.- Окно скрыто или скрипт завершён →
showCursor(false, false): курсор спрятан, управление возвращено. cursor = false(по умолчанию): библиотека курсор не трогает, вы вызываетеshowCursor(...)сами, как раньше.
Текущие настройки лежат в
Положение и размер после перетаскивания и изменения размера хранятся в
ul.opts, их можно менять в любой момент:
Lua:
ul.opts.draggable = false -- запретить двигать (например, пока открыт диалог)
ul.opts.resizable = true
ul.opts.lock_controls = false -- применится при следующем показе окна
ul.set_rect(200, 100, 600, 400) -- переместить и изменить размер из кода
print(ul.opts.x, ul.opts.y, ul.opts.w, ul.opts.h) -- текущие положение и размер
ul.opts, так что их можно сохранить в конфиг и подставить в ul.init при следующем запуске.Справочник по Lua-функциям
ul.init{ x, y, w, h, ...флаги }: создаёт окно (положение и размер в пикселях, не больше 1920×1080). Флагиdraggable, resizable, cursor, lock_controls, min_w, min_h, max_w, max_h, resize_borderописаны в разделе «Флаги окна». Повторный вызов (например, после перезагрузки скрипта) просто переиспользует уже запущенный хелпер.ul.status(): состояние хелпера: -1 упал или не запустился, 0 не создан, 1 запускается, 2 готов.ul.set_rect(x, y, w, h): переместить и/или изменить размер окна (значения сохраняются вul.opts).ul.set_visible(true/false): показать или скрыть. Скрытое окно не рисуется и не нагружает процессор (страница и таймеры при этом продолжают работать). Приcursor = trueзаодно включает или выключает курсор.ul.shutdown(): спрятать окно, очистить страницу и (приcursor = true) выключить курсор. Вызывайте вonScriptTerminate.
ul.create_page(name, html): создать HTML-файл из Lua-строки вmoonloader/resource/ul/<name>.html. Возвращаетurl, путь_на_диске. Имя очищается от лишних символов (остаются буквы, цифры,_и-).ul.load_url(url): загрузить страницу по адресу. Локальные файлы указываются относительно папки GTA:file:///moonloader/resource/ul/mypage.html.ul.load_html(html): загрузить HTML прямо из строки, без файла.ul.eval(js): выполнить произвольный JS-код в странице.
ul.push сбрасывается автоматически, значения придут заново.ul.call('имя', арг1, арг2, ...): вызвать JS-функцию. Аргументы передаются как JSON: числа, строки, булевы значения, таблицы.ul.push(таблица [, 'имя_js_функции']): отправить только изменившиеся значения. По умолчанию вызывается JS-функцияupdate(diff). Таблицы-значения (списки, вложенные объекты) сравниваются по содержимому. Возвращает true, если что-то было отправлено. Вызывайте в цикле сколько угодно: лишний трафик не создаётся.ul.reset_cache(): заставить следующийul.pushотправить всё заново (например, при повторном открытии окна).
ul.on('событие', function(данные) ... end): зарегистрировать обработчик. Данные приходят уже в виде Lua-значения (число, строка, таблица).ul.process(): забирает накопившиеся события и вызывает обработчики. Вызывайте каждый кадр в циклеmain(). Ошибки в обработчиках перехватываются и печатаются в консоль, скрипт не падает.- Служебные события:
'ready'(страница загрузилась, можно подписаться и отправить стартовые данные).
ul.window_message(msg, wparam, lparam): вызывайте изonWindowMessage. Передаёт в страницу движение мыши, нажатия кнопок (левая, средняя, правая), колесо прокрутки, клавиши и символы. Возвращает true, если сообщение нужно «съесть» (consumeWindowMessage(true, false)), чтобы игра не реагировала на клик по окну.- Мышь работает, пока курсор над окном. Курсор включайте флагом
cursor = trueвul.init(см. «Флаги окна») или вручную функциейshowCursor(show, lockControls):showCursor(true, true): показывает курсор и блокирует управление персонажем и камерой. Подходит для открытых меню, инвентарей, диалоговых окон.showCursor(true, false): показывает курсор, но управление персонажем и камерой остаётся активным. Удобно для кликабельных элементов во время ходьбы и специальных HUD.showCursor(false, false)(илиshowCursor(false)): прячет курсор и возвращает стандартное управление игрой.
- Клавиатура уходит в страницу только пока сфокусировано поле ввода (
<input>или<textarea>). Это определяется автоматически. - Русский ввод: символы приходят в ANSI и конвертируются в UTF-8 автоматически.
ul.ready: true послеul.initul.visible: показано ли окноul.keyboard: true, пока фокус в поле ввода (клавиатура направлена в окно)ul.opts: текущие настройки окна (x, y, w, hи все флаги), их можно менять на лету
Что должно быть на стороне страницы (JS)
HTML:
<script>
// Страница → Lua. Эту функцию библиотека добавляет сама, если вы не определили свою.
function lua(name, data) {
console.log('@lua ' + JSON.stringify({ e: name, d: data === undefined ? null : data }));
}
// Lua → страница: сюда приходит ul.push{...} (только изменившиеся поля)
function update(s) {
if (s.hp !== undefined) document.getElementById('hp').style.width = s.hp + '%';
}
</script>
Служебные события, которые страница отправляет сама (если вы пользуетесь автодобавлением):
__ready: страница загрузилась (сбрасывает кэш push и вызывает Lua-обработчикready)__kbd: фокус вошёл в поле ввода или вышел (включает и выключает передачу клавиатуры)__drag: нажали на «ручку» перетаскивания (работает приdraggable = true; JS для этого библиотека добавляет сама)
Если вы пишете свою функцию
lua(), эти события нужно отправлять самому: на DOMContentLoaded вызвать lua('__ready'), а на focusin и focusout полей ввода вызывать lua('__kbd', true/false).Примеры применения
Lua:
-- в main(), в цикле:
local t = 0
while true do
wait(0)
ul.process()
if os.clock() - t > 0.1 then -- 10 раз в секунду достаточно
t = os.clock()
ul.push { -- уйдут только изменившиеся значения
hp = getCharHealth(PLAYER_PED),
armor = getCharArmour(PLAYER_PED),
}
end
end
HTML:
<div class="bar"><div id="hp"></div></div>
<div class="bar"><div id="armor"></div></div>
<script>
function update(s) {
if (s.hp !== undefined) document.getElementById('hp').style.width = Math.min(100, s.hp) + '%';
if (s.armor !== undefined) document.getElementById('armor').style.width = Math.min(100, s.armor) + '%';
}
</script>
HTML:
<button onclick="lua('menu', {id: 1})">Начать работу</button>
<button onclick="lua('menu', {id: 2})">Купить</button>
Lua:
ul.on('menu', function(d)
if d.id == 1 then sampSendChat('/work') end
if d.id == 2 then sampSendChat('/buy') end
end)
HTML:
<input id="msg" placeholder="Введите текст">
<button onclick="lua('send', { text: document.getElementById('msg').value })">Отправить</button>
Lua:
ul.on('send', function(d)
sampAddChatMessage('Вы ввели: ' .. u8:decode(d.text), -1) -- из UTF-8 в cp1251
end)
Lua:
ul.push { items = {
{ name = 'Аптечка', count = 3 },
{ name = 'Отмычка', count = 1 },
}}
HTML:
<div id="list"></div>
<script>
function update(s) {
if (!s.items) return;
var box = document.getElementById('list');
box.innerHTML = '';
s.items.forEach(function (it) {
var row = document.createElement('div');
row.textContent = it.name + ' × ' + it.count;
box.appendChild(row);
});
}
</script>
Lua:
ul.call('notify', u8('Вы получили награду'), 'ok')
HTML:
<script>
function notify(text, kind) {
var el = document.createElement('div');
el.className = 'toast ' + kind;
el.textContent = text;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000); // исчезнет через 3 сек
}
</script>
Lua:
local MENU, SHOP = [==[ ...html меню... ]==], [==[ ...html магазина... ]==]
ul.load_url((ul.create_page('menu', MENU)))
ul.on('goto_shop', function() ul.load_url((ul.create_page('shop', SHOP))) end)
ul.on остаются, кэш push сбрасывается автоматически при каждой загрузке. Либо делайте одну страницу с несколькими блоками и переключайте их из JS.
Lua:
local car = isCharInAnyCar(PLAYER_PED) and storeCarCharIsInNoSave(PLAYER_PED)
ul.push {
inCar = car and true or false,
speed = car and math.floor(getCarSpeed(car) * 3.6) or 0, -- множитель подберите под свой сервер
}
Lua:
ul.init {
x = 200, y = 120, w = 480, h = 360,
draggable = true, resizable = true,
min_w = 320, min_h = 220,
cursor = true, lock_controls = false, -- курсор виден, но ходить можно
}
ul.load_url((ul.create_page('panel', HTML)))
HTML:
<style>
html, body { height:100%; margin:0; background:transparent; font-family:Arial; color:#fff; }
.card { height:100%; box-sizing:border-box; display:flex; flex-direction:column;
border-radius:12px; background:rgba(20,24,32,.92); overflow:hidden; }
.head { padding:10px 14px; background:rgba(255,255,255,.06); font-weight:bold; }
.body { flex:1; padding:14px; overflow:auto; }
.grip { position:absolute; right:10px; bottom:6px; opacity:.4; }
</style>
<div class="card">
<div class="head" data-ul-drag>Панель (тяните за заголовок)</div>
<div class="body">Содержимое растягивается вместе с окном.</div>
<div class="grip">◢</div>
</div>
Что ещё можно сделать:
- Панели информации (статистика, задания, рейтинги)
- Игровое меню, магазин, чекбоксы, переключатели, ползунки
- Окна чата, уведомления, подсказки, мини-карта на CSS/SVG
- Графики и диаграммы на SVG или Canvas
- Любой интерфейс, который вы бы сделали на сайте
Готовый скрипт: /ui, информация о персонаже
В комплекте идёт
ul_charinfo.lua, демонстрация всех возможностей. Команда /ui показывает и скрывает окно. Значения обновляются автоматически 10 раз в секунду (через ul.push). Окно можно двигать мышью, а курсор включает сама библиотека (флаги draggable = true, cursor = true).Что показывает:
- Игрок: ник, ID, цвет, деньги, score, розыск, пинг, скин, состояние (пешком, в машине, в воде, в воздухе), HP и броня
- Позиция: координаты, угол, интерьер, район
- Транспорт: модель, HP, скорость, двигатель, водитель или пассажир (пешком вместо этого показывается скорость движения)
- Оружие: то, что в руках, и все слоты с патронами
- Сервер: название, адрес, онлайн, FPS, игровое время и время ПК
Все игровые функции в скрипте обёрнуты в безопасный вызов: если в вашей сборке MoonLoader или SAMPFUNCS какой-то функции нет, соответствующее поле остаётся пустым, а окно продолжает работать. HTML окна лежит внутри самого .lua файла и создаётся скриптом.
Ограничения
- ОДНО ОКНО НА ВСЮ СБОРКУ (может вскоре попробую пофиксить)!!! Несколько элементов интерфейса делайте внутри одной страницы.
- Окно ловит мышь по всему своему прямоугольнику, в том числе по прозрачным местам. Для чисто декоративного HUD держите окно небольшим, либо не вызывайте
ul.window_message, тогда клики пройдут в игру. - Максимальный размер окна: 1920×1080.
- При
resizable = trueполоса по краям окна (resize_border, 8 px) принадлежит библиотеке: клики по ней в страницу не доходят. Форма курсора при наведении на край не меняется. - Рендер программный (CPU). WebGL недоступен, HTML5 видео и аудио экспериментальные.
- Ultralight построен на WebKit, поэтому самые новые возможности JS и CSS могут не поддерживаться. Проверяйте на своей странице.
- Рассчитано на GTA SA 1.0 US. Для другой версии игры нужно поменять адрес в
asi/ultralight_lua.cpp(константаkGtaDevicePtr). - Библиотека не заменяет и не перехватывает встроенные CEF-интерфейсы лаунчеров. Это отдельный способ рисовать собственный интерфейс.
Производительность
- Рендер идёт не чаще 60 раз в секунду и только пока окно показано.
- Новый кадр передаётся в игру только если картинка изменилась.
- Окно 520×640 передаёт около 1,3 МБ на кадр: на практике это незаметно.
- Не вызывайте
ul.callкаждый кадр с одними и теми же данными: используйтеul.push, он отправляет только изменения. - Для данных из игры достаточно опроса 5-10 раз в секунду.
Если что-то не работает
- Выведите в консоль
print(ul.status()): -1 хелпер не запустился (проверьте, чтоGTA/ultralight/ul_helper.exeи все DLL на месте и что DLL именно x64); 1 запускается; 2 работает. - Лог Ultralight:
GTA/ultralight/ultralight.log. - Вызываете ли вы
ul.process()в цикле иul.set_visible(true)? - Не за пределами ли экрана окно (проверьте
x, y, w, h)?
Не найдена папка ресурсов. Скопируйте
icudt67l.dat и cacert.pem из Ultralight SDK в GTA\ultralight\resources\. Хелпер при старте сам проверяет файл и показывает полный путь, который искал.Текст страницы и данные для неё должны быть в UTF-8. Сохраняйте скрипт в UTF-8, а строки из игры (ник, название сервера) передавайте через
u8(...). Строки, пришедшие из страницы, декодируйте через u8:decode(...) перед выводом в чат.Проверьте, что скрипт вызывает
ul.window_message из onWindowMessage и consumeWindowMessage(true, false), а курсор включён (флаг cursor = true в ul.init либо ваш вызов showCursor(true, true) или showCursor(true, false)).- Включены ли флаги
draggable = trueиresizable = trueвul.init? - Вызывается ли
ul.process()каждый кадр? Перетаскивание начинается по событию из страницы, а его обрабатывает именноul.process(). - Вызывается ли
ul.window_messageизonWindowMessage? - Если в странице есть
data-ul-drag, двигать можно только за эти элементы. Нажатие на кнопки, ссылки и поля ввода окно не двигает. - Если страница загружена не через
ul.create_pageили содержит свой код с__drag, служебный JS не добавляется: вызовитеlua('__drag')наmousedownсами. - Для изменения размера берите окно ровно за край (в пределах
resize_border). Форма курсора не меняется, это нормально.
Хелпер должен завершаться вместе с игрой. Если остался процесс, завершите его вручную в диспетчере задач и сообщите об этом в теме.
Важно знать
- Библиотека запускает дополнительный процесс (
ul_helper.exe) и использует ASI-плагин с хуком D3D9. На серверах с античитом или особыми правилами уточняйте, разрешено ли такое. - Ultralight бесплатен для некоммерческого использования. Условия лицензии смотрите на сайте и в поставке SDK.
Скачать
UltraLight-moonloader
пасиб за лайки. завтра пофишку мультиоконность и уберу копирование из окна.
Последнее редактирование: