В BeamMP нет игровых режимов. Сервер BeamMP пересылает состояние машин и выполняет Lua, а всё, что превращает сервер в нечто большее, чем парковку, - гонки, белые списки, правила для машин, команды в чате, мост в Discord, - это Lua-плагины в Resources/Server. Серверный API достаточно мал, чтобы освоить его за вечер: вы регистрируете обработчики на десяток событий, вызываете пару десятков функций MP.* и используете таймеры для всего, что растянуто во времени. Этого хватает и для настоящих гонок, потому что сервер может прочитать последнюю присланную позицию каждой машины через MP.GetPositionRaw, а больше системе чекпоинтов ничего и не нужно. В этом руководстве разобраны модель плагинов, события и функции, которыми вы действительно будете пользоваться, и полноценный плагин гонки с обратным отсчётом, воротами, кругами и временем финиша.
Установка, ServerConfig.toml, карты и клиентские моды разобраны в руководстве по серверу BeamMP - прочитайте его сначала, если сервер ещё не запущен. Всё здесь рассчитано на сервер 3.x, который использует Lua 5.3. Старые плагины, написанные под API 2.x или под давно заброшенные фреймворки сообщества, часто не загружаются, и это первое, что стоит проверить, когда скачанный скрипт ничего не делает.
Как загружаются серверные плагины#
Плагин - это папка внутри Resources/Server. Сервер загружает каждый файл .lua из корня этой папки в алфавитном порядке и один раз выполняет код верхнего уровня. Файлы во вложенных папках игнорируются, если вы не подключите их через require, - это правильное место для вспомогательного кода и данных.
Resources/ Client/ mod zips sent to players Server/ RaceControl/ main.lua loaded at start lib/gates.lua only if main.lua requires it Whitelist/ main.luaКаждый плагин работает в собственном состоянии Lua. Два плагина не могут читать переменные друг друга; они общаются через события, о которых ниже. Внутри плагина схема всегда одна: объявляете глобальные функции, затем привязываете их к именам событий через MP.RegisterEvent(eventName, functionName). Второй аргумент - это имя глобальной функции в виде строки, а не сама функция, поэтому обработчик, объявленный как local, молча никогда не срабатывает.
Сервер ещё и следит за папкой плагина. Сохранение файла в корне плагина вызывает onFileChanged и перезагружает состояние этого плагина, так что можно вносить правки без перезапуска сервера. Перезагрузка стирает переменные плагина, и идущая гонка пропадает, как только вы сохраняете main.lua; правьте между гонками, а не во время них.
Вывод идёт в консоль сервера. print пишет с префиксом [LUA], а Util.LogInfo, Util.LogWarn, Util.LogError и Util.LogDebug - с соответствующим уровнем. Синтаксическая ошибка выводится при загрузке с файлом и строкой, и плагин пропускается. Если плагин «ничего не делает», ответ - в консоли с момента загрузки; как читать её эффективно, рассказано в статье чтение консоли.
События, которыми вы будете пользоваться#
| Событие | Аргументы | Можно отменить |
|---|---|---|
onInit | нет | Нет |
onPlayerAuth | name, role, is_guest, identifiers | Да - верните 1 или строку с причиной |
onPlayerConnecting | player_id | Нет |
onPlayerJoining | player_id | Нет |
onPlayerJoin | player_id | Нет |
onPlayerDisconnect | player_id | Нет |
onChatMessage | player_id, player_name, message | Да - верните 1, чтобы скрыть сообщение |
onVehicleSpawn | player_id, vehicle_id, data | Да - верните 1, чтобы запретить спавн |
onVehicleEdited | player_id, vehicle_id, data | Да - верните 1, чтобы запретить правку |
onVehicleReset | player_id, vehicle_id, data | Нет |
onVehicleDeleted | player_id, vehicle_id | Нет |
onConsoleInput | input | Нет |
onShutdown | нет | Нет |
Основную работу делают три из них. onPlayerAuth срабатывает до того, как у игрока появляется ID, поэтому здесь живут белые списки и баны; он получает таблицу identifiers с ID игрока в beammp, IP и, если привязан, Discord ID. onChatMessage - это то, через что реализуется любая команда чата: реестра команд нет, текст вы разбираете сами. onVehicleSpawn и onVehicleEdited - место для правил по машинам: аргумент data содержит конфигурацию машины в JSON, так что можно запретить модель, деталь или спавн вне боксов.
Отмена делается возвращаемым значением, и только им. Если вернуть 1 из onChatMessage, сообщение скроется от всех - именно это нужно для команд: никому не надо видеть /start в чате. Если ничего не возвращать, сообщение пройдёт.
Функции, которые стоит знать#
| Функция | Что делает |
|---|---|
MP.SendChatMessage(id, text) | Сообщение в чат одному игроку или всем с -1 |
MP.GetPlayers() | Таблица «ID игрока - имя» |
MP.GetPlayerName(id) | Имя по ID |
MP.GetPlayerIdentifiers(id) | beammp, ip и discord, если известны |
MP.IsPlayerGuest(id) | Нет ли у игрока аккаунта BeamMP |
MP.GetPlayerVehicles(id) | Таблица «ID машины - данные машины» |
MP.GetPositionRaw(id, vid) | Последние присланные pos, rot, vel, rvel, ping и tim |
MP.RemoveVehicle(id, vid) | Удаляет машину |
MP.DropPlayer(id, reason) | Кикает с сообщением |
MP.CreateEventTimer(name, ms) | Вызывает событие name каждые ms миллисекунд |
MP.CancelEventTimer(name) | Останавливает его |
MP.TriggerLocalEvent(name, ...) | Вызывает обработчики в этом плагине синхронно |
MP.TriggerGlobalEvent(name, ...) | Вызывает обработчики во всех плагинах |
MP.TriggerClientEvent(id, name, data) | Отправляет строку в клиентский мод |
MP.Set(MP.Settings.MaxCars, n) | Меняет значение настройки до перезапуска |
MP.CreateTimer() | Секундомер с :Start() и :GetCurrent() в секундах |
Позиции - это векторы в массивах Lua, так что raw.pos[1], raw.pos[2] и raw.pos[3] - это x, y и z. В BeamNG вверх направлена ось z, поэтому плоскость земли - это x и y. Util.JsonEncode и Util.JsonDecode превращают таблицы в строки и обратно; это нужно и для сохранения данных, и для общения с клиентскими модами. FS.Exists, FS.CreateDirectory и FS.ConcatPaths переносимо работают с файлами и путями.
Сигнатуры между версиями сервера меняются, и официальный справочник по скриптам для вашей версии важнее любой таблицы в блоге, включая эту.
Команды чата и админы#
Встроенного списка админов нет, так что кто админ - решает плагин. Привязывайтесь к идентификатору beammp, а не к отображаемому имени, которое игроки могут поменять:
local ADMINS = { ["1234567"] = true }local function isAdmin(pid) local ids = MP.GetPlayerIdentifiers(pid) return ids ~= nil and ADMINS[tostring(ids.beammp)] == trueendfunction AdminChat(pid, name, msg) local cmd, arg = msg:match("^/(%S+)%s*(.*)$") if not cmd then return end if not isAdmin(pid) then return end if cmd == "kick" then for id, pname in pairs(MP.GetPlayers()) do if pname == arg then MP.DropPlayer(id, "Kicked by " .. name) end end elseif cmd == "cars" then MP.Set(MP.Settings.MaxCars, tonumber(arg) or 1) MP.SendChatMessage(-1, "Car limit is now " .. arg) else return end return 1endMP.RegisterEvent("onChatMessage", "AdminChat")Шаблон ^/(%S+)%s*(.*)$ разбивает /kick SomeName на команду и остаток строки. Неизвестные команды проходят дальше и появляются в чате - это более дружелюбный вариант отказа. MP.Set меняет настройку только для работающего сервера; при следующем перезапуске вернётся значение из ServerConfig.toml, что идеально подходит для ивентов.
Правила для машин при спавне#
Аргумент data у onVehicleSpawn - строка с префиксом перед JSON, поэтому перед декодированием отрежьте всё до первой фигурной скобки. Прежде чем писать правила, залогируйте один спавн: имена полей приходят из игры, и надёжнее всего их просто посмотреть:
local ALLOWED = { covet = true, sunburst = true }function CarSpawn(pid, vid, data) local start = data:find("{") if not start then return end local cfg = Util.JsonDecode(data:sub(start)) print("spawn by " .. MP.GetPlayerName(pid) .. ": " .. tostring(cfg.jbm)) if cfg.jbm and not ALLOWED[cfg.jbm] then MP.SendChatMessage(pid, "Only the race cars are allowed tonight.") return 1 endendMP.RegisterEvent("onVehicleSpawn", "CarSpawn")MP.RegisterEvent("onVehicleEdited", "CarSpawn")Повесьте тот же обработчик и на onVehicleEdited, иначе игроки заспавнят разрешённую машину, а потом поменяют её в конфигураторе. Запрещённый спавн ничего не оставляет на экранах других игроков; машина, удалённая позже через MP.RemoveVehicle, сначала мелькнёт, так что по возможности отказывайте именно при спавне.
Плагин гонки по чекпоинтам#
Гонке нужны четыре вещи: список участников, обратный отсчёт, способ понять, что машина проехала точку, и часы. У сервера есть все четыре. Ворота - это точки на карте с радиусом; каждый гонщик должен проехать их по порядку, а проезд последних ворот завершает круг. Позиции опрашиваются по таймеру.
local ADMINS = { ["1234567"] = true }local GATES = { -- fill in with /pos, finish line last { x = 0.0, y = 0.0, r = 15 }, { x = 0.0, y = 0.0, r = 15 },}local LAPS = 3local race = { state = "idle", count = 0, racers = {}, finished = 0 }local clock = MP.CreateTimer()local function say(pid, text) MP.SendChatMessage(pid, "[Race] " .. text) endlocal function isAdmin(pid) local ids = MP.GetPlayerIdentifiers(pid) return ids ~= nil and ADMINS[tostring(ids.beammp)] == trueendlocal function firstVehicle(pid) local vehicles = MP.GetPlayerVehicles(pid) if vehicles == nil then return nil end for vid, _ in pairs(vehicles) do return vid endendlocal function reset() MP.CancelEventTimer("RaceCountdown") MP.CancelEventTimer("RaceTick") race = { state = "idle", count = 0, racers = {}, finished = 0 }endfunction RaceChat(pid, name, msg) local cmd = msg:match("^/(%S+)") if cmd == "join" and race.state == "idle" then race.racers[pid] = { gate = 1, lap = 1, done = false } say(-1, name .. " is on the grid") elseif cmd == "pos" then local vid = firstVehicle(pid) local raw = vid and MP.GetPositionRaw(pid, vid) if raw and raw.pos then say(pid, string.format("x=%.1f y=%.1f", raw.pos[1], raw.pos[2])) end elseif cmd == "start" and isAdmin(pid) and race.state == "idle" then race.state = "countdown" race.count = 5 MP.CreateEventTimer("RaceCountdown", 1000) elseif cmd == "stop" and isAdmin(pid) then reset() say(-1, "Race cancelled") else return end return 1endfunction RaceCountdown() if race.count > 0 then say(-1, tostring(race.count)) race.count = race.count - 1 return end MP.CancelEventTimer("RaceCountdown") race.state = "running" clock:Start() say(-1, "GO") MP.CreateEventTimer("RaceTick", 200)endfunction RaceTick() for pid, r in pairs(race.racers) do local vid = firstVehicle(pid) local raw = vid and MP.GetPositionRaw(pid, vid) if raw and raw.pos and not r.done then local g = GATES[r.gate] local dx, dy = raw.pos[1] - g.x, raw.pos[2] - g.y if dx * dx + dy * dy <= g.r * g.r then r.gate = r.gate + 1 if r.gate > #GATES then r.gate = 1 if r.lap >= LAPS then r.done = true race.finished = race.finished + 1 say(-1, string.format("P%d %s %.2fs", race.finished, MP.GetPlayerName(pid), clock:GetCurrent())) else r.lap = r.lap + 1 end end end end endendfunction RaceLeave(pid) race.racers[pid] = nil endMP.RegisterEvent("onChatMessage", "RaceChat")MP.RegisterEvent("RaceCountdown", "RaceCountdown")MP.RegisterEvent("RaceTick", "RaceTick")MP.RegisterEvent("onPlayerDisconnect", "RaceLeave")Чтобы настроить его, проедьте к каждой точке трассы - с шагом в несколько сотен метров, в поворотах, которые нельзя срезать, - и наберите /pos. Скопируйте x и y в GATES в порядке движения, причём линия старта-финиша должна быть последней записью, чтобы машины со старта ехали сначала к первым воротам. Сохраните файл, плагин перезагрузится, гонщики набирают /join, а затем админ - /start.
Насколько это точно, если честно
Сервер знает только, где машина была в момент последнего обновления, поэтому точность зависит от двух вещей. Опрос каждые 200 мс означает, что машина на скорости 200 км/ч проходит между проверками около 11 метров, - поэтому радиус ворот 15 метров, а не три. А каждая позиция устарела на величину сетевой задержки игрока, так что пилота с пингом 150 мс засекают чуть позже. На практике это несколько десятых секунды: для воскресной гонки ради удовольствия нормально, для чемпионата, где всё решают сотые, - нет.
Есть два способа сделать точнее. Если уменьшить тик до 100 мс, проблема расстояния уменьшится вдвое, а работы станет вдвое больше; при паре гонщиков это мелочь, при сорока - уже нет. Для настоящей точности определять пересечение нужно на машине каждого игрока: клиентский мод в Resources/Client локально отслеживает проезд ворот и сообщает о нём через TriggerServerEvent, а серверный плагин получает это через MP.RegisterEvent с обработчиком, который принимает ID игрока и строку с данными. Это больше работы и требует клиентского мода, который вы будете поддерживать, поэтому большинство серверов сообщества используют простой серверный подход для развлекательных ивентов и отдельный мод тайминга для лиг.
Чтобы гонки ощущались гонками
Несколько дешёвых дополнений:
- Заморозьте состав перед гонкой: поставьте
MaxCarsв1черезMP.Set, чтобы никто не заспавнил вторую машину посреди гонки, и верните значение после. - Не допускайте ресетов в гонке.
onVehicleResetотменить нельзя, но можно дисквалифицировать: если гонщик сделал ресет, покаrace.stateравенrunning, выставьтеdoneи объявите об этом. - Публикуйте результаты в Discord: пишите их в файл, который читает отдельный бот, или используйте плагин для вебхуков - принимающая сторона разобрана в статье вебхуки Discord для статуса сервера.
- Храните трассы как данные. Положите ворота каждой трассы в отдельный файл в
lib/и подключайте нужный черезrequire, чтобы смена площадки не требовала правки плагина.
Сохранение данных между перезапусками#
Переменные плагина умирают вместе с сервером. Всё, что должно пережить перезапуск, - рекорды кругов, бан-лист, история гонок - нужно записывать на диск. Библиотеки io из Lua и Util.JsonEncode для этого достаточно:
local DIR = "Resources/Server/RaceControl/data"local FILE = FS.ConcatPaths(DIR, "records.json")local function loadRecords() if not FS.Exists(FILE) then return {} end local f = io.open(FILE, "r") local text = f:read("*a") f:close() return Util.JsonDecode(text) or {}endlocal function saveRecords(records) if not FS.Exists(DIR) then FS.CreateDirectory(DIR) end local f = io.open(FILE, "w") f:write(Util.JsonEncode(records)) f:close()endПути считаются от рабочей папки сервера. Запись файла в корневую папку самого плагина может вызвать горячую перезагрузку этого плагина, которая сотрёт гонку, только что записавшую рекорд, - поэтому кладите файлы данных во вложенную папку вроде data/, как выше: её сервер не загружает как код плагина. Это самая запутанная ошибка в разработке плагинов для BeamMP, и выглядит она так, будто сервер перезапускает плагин случайным образом.
Эти файлы данных - та часть сервера, которую нельзя скачать заново. Бэкапьте Resources/Server перед каждой сессией правок и перед каждым обновлением сервера; статья бэкапы, которые действительно восстанавливаются объясняет, почему один из них стоит реально проверить.
Связь между плагинами и с клиентскими модами#
Плагины изолированы, поэтому плагин белого списка не может вызвать функцию из плагина гонки. Мост между ними - события: MP.TriggerGlobalEvent("RaceFinished", pid, time) в одном плагине вызывает каждый обработчик RaceFinished во всех плагинах. MP.TriggerLocalEvent делает то же внутри одного плагина и возвращает результат синхронно, что удобно, когда большой плагин разбит на файлы.
Клиентские моды - это обратное направление. Zip-архив в Resources/Client может содержать Lua, который выполняется в игре у каждого игрока, регистрирует обработчики через AddEventHandler и отправляет данные через TriggerServerEvent. Сервер отправляет через MP.TriggerClientEvent(pid, name, data) или MP.TriggerClientEventJson для таблицы. Всё передаётся строками, так что кодируйте таблицы в JSON с обеих сторон. Используйте это для интерфейса - обратный отсчёт на экране вместо чата, таймер круга, таблица лидеров, - но решающее слово оставляйте за сервером: клиентский мод, который сам сообщает своё время круга, может отредактировать игрок, у которого он запущен.
Безопасность, производительность и работоспособность плагинов#
Плагины работают с полными правами сервера и видят каждую строку чата и каждое подключение. Читайте всё, что скачиваете, прежде чем класть в папку, которая загружается автоматически, и отдавайте предпочтение плагинам с открытым репозиторием. Общая гигиена описана в статье как держать модифицированный сервер в чистоте, а вредоносные плагины и моды - о том, что может сделать враждебный плагин.
Проблемы с производительностью в BeamMP почти всегда сводятся к обработчику, который делает слишком много и слишком часто. Таймер на 50 мс, который перебирает всех игроков и декодирует JSON, проявится неожиданной нагрузкой на CPU. Util.DebugExecutionTime() возвращает статистику времени по каждому обработчику и превращает догадку в число. Никогда не вызывайте MP.Sleep надолго внутри обработчика: это блокирует плагин.
На RE:NODE тариф BeamMP даёт консоль с историей для чтения вывода плагинов, редактор в файловом менеджере с подсветкой синтаксиса для main.lua и SFTP, если удобнее работать локально. Сохранение файла плагина перезагружает его на лету, так что цикл «правка - проверка» не требует перезапуска, а бэкап, сделанный перед сессией правок, - это кнопка отмены для всей папки Resources.
После обновления сервера BeamMP прочитайте changelog на предмет изменений в скриптах, прежде чем перезапускать сервер, полный плагинов. Если выяснили это на собственном опыте, порядок восстановления - в статье что делать, когда обновление мода всё сломало.
Решение проблем#
Обработчик никогда не срабатывает. Функция объявлена как local, имя написано с ошибкой в MP.RegisterEvent или у имени события не тот регистр. Имена событий чувствительны к регистру.
Плагин сбрасывается сам по себе. Он пишет файл в собственную корневую папку и вызывает горячую перезагрузку. Перенесите данные во вложенную папку.
`attempt to index a nil value` при работе с позициями. У игрока нет машины, или MP.GetPositionRaw вернул пустой результат. Проверяйте raw и raw.pos перед использованием.
Команды появляются в чате. Обработчик не возвращает 1 для обработанных команд.
Ворота никогда не срабатывают. Координаты скопированы как x и z вместо x и y, или радиус меньше расстояния, которое машина проходит между опросами.
Старый плагин ничего не делает. Он написан под API 2.x. Ищите MP.RegisterEvent со строковыми именами обработчиков; всё остальное нужно портировать.
FAQ#
Есть ли в BeamMP встроенные гоночные режимы?
Нет. У сервера вообще нет игровых режимов. Гонки, тайминг и таблицы лидеров - это Lua-плагины, написанные самостоятельно или скачанные, иногда вместе с клиентским модом для отображения времени на экране.
Какую версию Lua использует сервер BeamMP?
Lua 5.3 на сервере 3.x. Плагины под старый API 2.x нужно портировать - в основном на строковые обработчики MP.RegisterEvent и актуальные имена событий.
Нужно ли игрокам что-то устанавливать для серверных плагинов?
Для серверных плагинов в Resources/Server - нет. К игрокам попадают только клиентские моды из Resources/Client, и они скачиваются автоматически при подключении.
Насколько точен серверный тайминг кругов?
В пределах нескольких десятых секунды - его ограничивают интервал опроса и пинг каждого игрока. Для развлекательных гонок этого достаточно. Соревновательному таймингу нужно определение на клиенте с передачей на сервер.
Можно ли поставить пароль через плагин?
Поля для пароля нет, но можно отклонять в onPlayerAuth всех, чьего идентификатора beammp нет в вашем списке, - это строже пароля, потому что его нельзя передать другому.
Нужно ли перезапускать сервер после правки плагина?
Нет. Сохранение файла в корневой папке плагина перезагружает этот плагин. Его переменные сбрасываются, так что делайте это между ивентами, а не во время них.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.