❯ Что за игра?
Больше всего этот продукт подходит для сравнения с серией игр X: разработчики очень вдохновлялись тем, как там устроен интерфейс и взаимодействие между кораблем и станциями. Но, есть очень жирный бонус — корабли можно строить из блоков, подгонять их под разный размер, и буквально творить чудеса. У нас а-ля «Майнкрафт». Да, астероиды тоже можно майнить.

Игра имеет тесное взаимодействие со Стимом, и умельцы очень радо подгружают туда свои наработки (моды, корабли, станции и прочее). Каждый корабль, конечно же, стоит определенного количества игровых ресурсов. В игре присутствует сетевой режим, и бороздить просторы галактик, торговать и бить пиратов можно далеко не в одиночку. Процедурная генерация делает каждую галактику уникальной!

К слову о карте, она имеет размер 1000x1000 секторов, что по меркам геймплея — очень большое число. Каждый прыжок из сектора в сектор занимает порядка 10 секунд, и полностью зависит от мощности вашей посудины.

Главная цель игры — добраться до центра галактики. Чем ближе к центру, тем реже и ценнее мы встречаем материалы, из которых и строятся наши корабли. Более ценный материал добавляет им прочности. Но, с путем к центру нужно быть внимательным, так как корабли недоброжелателей будут сделаны как раз из более дорогих и уроностойких материалов.
Лично для себя искал космосим чем-то похожий на Freelancer из 2003. Главным требованием я поставил вид от 3-го лица, так как хочу видеть свой корабль, ну и, конечно же, торговлю. Куда ж без нее.
Идея для мода у меня появилась, когда я поймал себя на мысли, что уже не помню, какую цену я заплатил за тот или иной товар. В игре больше 100 торгуемых предметов — кое-что фабрики производят, а кое-что потребляют. Все по классике торговых симуляторов.
В общем, суть мода — создать интерфейс, куда будут записываться мои торговые сделки. Хотелось простого автоматического запоминания цены, которую я заплатил за товар. И цена должна быть за единицу, так как подсчет общей суммы сделки перед самой сделкой в интерфейсе покупок тоже не реализован — мы видим только цену за единицу товара.
❯ Дебри игры
Ядро игры написано на плюсах, а вот обертка и скрипты писаны на Lua. В саму игру моды предусмотрены, что не может не радовать. Также, есть документация по ее API, но, пока я писал мод... она очень вялая и не описана. Фраза «Tip: Scroll down for an example script!» буквально вела на пример, который содержит только описание функций без реального примера!
Больших гайдов о том, как делать моды для этой игры, я так и не нашел. Парочка страниц на fandom.com конечно же сильно помогли, но, опять таки, статей, почему мы используем X, а не Y я не видел. Все что оставалось, это копать моды других мододелов, но там комментариев практически нету.
Также, в игре очень не понятно куда и как пихать скрипты. Видел пример из другого мода, где используется реализация игровой музыки и туда же кто-то из мододелов добавил свой хук.
❯ Пишем мод
Для начала, нам предстоит разобраться с тем, как игра принимает наш мод (скажем так, найти точку входа). В основном, подход к мододеланью в Avorion реализован через расширение существующих скриптов. То есть, мы видим в файлах игры какой-то скрипт, создаем наш мод и делаем в нем структуру папок и файлов, которая идентична той, что мы видим в файлах игры. Смотря на папку с игрой, мы видим, что в ней есть папка data/scripts и там довольно много всего на Lua. Что же, наш мод будет дополнять какой-то из этих файлов.
D:\SteamLibrary\steamapps\common\Avorion\data\scripts>tree /F Folder PATH listing for volume Data Volume serial number is D03D-733D D:. │ scripts.db │ sectorspecifics.lua │ startsector.lua │ ├───alliance │ init.lua │ ├───client │ crafticons.lua │ silhouettes.lua ...
Найти что же из всего этого будет дополнятся было крайне сложно, но выбор пал на файл data/scripts/entity/init.lua — в нем происходит довольно много подгрузок остальных скриптов, и по моему мнению, этот файл и есть такой себе точкой входа. Хотя, это не точно :)
Гайды советуют нам создать вот такую структуру в appdata. Эту папку сканирует игра и показывает нам наш мод в настройках.
C:\Users\User\AppData\Roaming\Avorion\mods\MyMod>tree /F Folder PATH listing for volume Windows Volume serial number is 483C-589B C:. │ modinfo.lua │ thumbnail.png │ └───data └───scripts └───entity init.lua tradinghistory.lua
Коротко по файлам:
modinfo.lua— файл с метаданными о моде;init.lua— наш главный файл, которым мы будем расширять init.lua из файлов игры;tradinghistory.lua— код нашего мода;thumbnail.png— превьюшка мода при его подгрузке в воркшоп Стима.
Начнем с метаданных в modinfo.lua:
meta = { id = "3747386910", name = "Trading History Mod", title = "Trading History Mod", type = "mod", description = "Provides a separate UI button that will display the price you've bought the good for from the stations", authors = {"alexM8"}, version = "0.1.1", dependencies = { {id = "Avorion", max = "2.5.13"} }, serverSideOnly = false, clientSideOnly = false, saveGameAltering = false, contact = "email@email.com", }
id генерируется рандомно — просто 10 чисел. По этой айдишке воркшоп стима будет идентифицировать мод при его подгрузке. Здесь dependencies включает только версию игры, но также может включать в себя и другие моды. saveGameAltering стоит выставить в true, если мод будет теоретически ломать старые сохранения игры — это будет предупреждать пользователя о возможном ущербе. client/server-SideOnly ограничивают мод для работы на клиенте или на сервере, но обо всем по порядку. Все остальные поля довольно очевидны.
Глядя на остальные скрипты, я заметил тесную связь с Entity() и Player(). Я бы рассказал, что такое Entity() и Player() более детально, но документация об этом умалчивает. Как я понимаю, это базовые конструкции, которые дают хендлеры к внутриигровым объектам. Мы подцепим наш скрипт к Entity() вот таким образом в наш init.lua:
local entity = Entity() if valid(entity) then if not entity:hasScript("data/scripts/entity/tradinghistory.lua") then entity:addScript("data/scripts/entity/tradinghistory.lua") end end
Проверка на то, подгружен скрипт или нет критически важна. Мы хотим загрузить его только 1 раз. А так как сама игра очень сложная и многопоточная, исполнение игровых скриптов может быть не единоразовое.
Также стоит сказать, что у игры клиент-серверная архитектура. Даже в сингл плеере поднимается инстанс сервера и клиент к нему коннектится. Все скрипты что мы пишем, исполняются как на клиенте, так и на сервере. Оборачивая код в if onServer() или в if onClient(), мы ограничиваем скрипты в пространстве их исполнения там, либо там.
В файле tradinghistory.lua мы начнем с объявления неймспейса и локальных переменных. Только, объявить неймспейс нам тоже нужно правильно.
local ui local window local tabs local list -- namespace TheUI TheUI = {}
В Lua все, что начинается с двух минусов, является комментарием, но по правилам этого игрового движка, если в комментарии есть -- namespace, он загрузит этот неймспейс в свое «пространство», поэтому данный комментарий критически важен. Имя неймспейса было выбрано случайно. А вот саму информацию об этом комментарие пришлось брать из модов от других мододелов.
Далее, мы займемся таким себе «фронтендом». То есть, напишем код который будет выводить менюшку, куда и будет записываться вся наша история торгов.
function TheUI.initUI() ui = ScriptUI(Entity()) local res = getResolution() local size = vec2(600, 400) window = ui:createWindow(Rect(res * 0.5 - size * 0.5, res * 0.5 + size * 0.5)) window.caption = "Trade History" window.showCloseButton = true window.moveable = true tabs = window:createTabbedWindow(Rect(vec2(10, 10), vec2(590, 390))) buildTabs() ui:registerWindow(window, "Trade History", 1) end
Функции .initUI(), также как и ScriptUI() нам дает API. Это то, на чем мы базируем взаимодействие нашего мода с игроком. Также, документация говорит нам о том, что данная функция будет исполняться только на клиенте. Все довольно просто — берем разрешение экрана у игрока, множим его на доли чтобы менюшка не была во весь экран, выставляем опции для окна, создаем в нем таб, и регистрируем окно c его названием.
function buildTabs() local tab = tabs:createTab("Info", "data/textures/icons/info.png", "Info") local margin = 3 local buttonHeight = 40 local spacing = 10 local size = tab.size local listRect = Rect( vec2(margin, margin), vec2(size.x - margin, size.y - margin - buttonHeight - spacing) ) list = tab:createListBoxEx(listRect) list.columns = 2 local res = getResolution() list.rowHeight = math.floor(res.y * 0.02) list:setColumnWidth(0, listRect.width * 0.6) list:setColumnWidth(1, listRect.width * 0.2) local buttonRect = Rect( vec2(margin, size.y - buttonHeight), vec2(size.x - margin, size.y - 10) ) local btn = tab:createButton(buttonRect, "Clear", "onClearClicked") end
Это постройка содержимого таба — здесь и будет наш список истории торгов. В tabs:createTab мы выставляем название таба и его иконку (берется из файлов игры). Через tab:createListBoxEx и list:setColumnWidth как раз создаются список и колонки. Мы создаем 2 колонки — для названия товара и его цены за единицу. Благодаря множителям в list:setColumnWidth, мы регулируем дистанцию в колонках. Кроме всего, у нас должен быть функционал очистки списка. Для этого строится прямоугольник кнопки, как и сама кнопка. Также, у кнопки есть onClearClicked. Это функция, которую мы обьявим позже. Она будет чистить список в случае переполнения или для удобства игрока.

Все это дело на начальных этапах выглядит вот так. У пользователя в менюшке справа сверху появляется иконка пазла. По нажатию открывается этот самый список. Кстати — он scrollable.

Теперь, часть «бекенда». Здесь немножко сложно, и мой код может быть не идеален. Функция .initialize() специальная. Как я это понял — она вызывается раз на неймспейс, но могу ошибаться.
local lastMoney = 0 function TheUI.initialize() local player = Player() lastMoney = player.money Entity():registerCallback("onCargoChanged", "onCargoChanged") print("registered callback for cargo price tracking") Player():registerCallback("onResourcesChanged", "onResourcesChanged") print("registered callback for resources and money tracking") end
Здесь мы должны зарегистрировать переменную lastMoney, которая будет изначально нулем, а в инициализаторе будет выставлено количество кредитов на счету у игрока. API у игры довольно ограниченное: мы не можем напрямую сделать реакцию на то, что игрок что-то покупает у станции. Максимум, что мы можем, и от чего будем базировать наш мод — это реакция на изменение содержимого хранилища товара на корабле. Для этого у игры есть коллбеки. Тоесть, мы регистрируем наши Lua-шные функции на какое-то игровое событие. Для этого мы и делаем вызовы registerCallback от Entity() или от Player(). К примеру, onCargoChanged является именем коллбека, а также именем нашей функции, которая будет вызываться каждый раз при его отработке (поэтому в registerCallback мы передаем 2 одинаковых аргумента «onCargoChanged»). Сам onCargoChanged принимает 3 параметра. Те, чем мы будем пользоваться — это delta и good. Соответственно — сколько и чего купил/продал.
function TheUI.onCargoChanged(objectIndex, delta, good) if onClient() then return end local player = Player() if not player then return end local currentMoney = player.money local moneySpent = lastMoney - currentMoney local exactPricePaid = 0 exactPricePaid = -(moneySpent / delta) * (delta < 0 and -1 or 1) lastMoney = currentMoney -- avoid adding collected loot if exactPricePaid == 0 then return end local raw = player:getValue("HistoryTable") or "" local history = deserializeHistory(raw) table.insert(history, {good.name, exactPricePaid}) player:setValue("HistoryTable", serializeHistory(history)) invokeClientFunction(player, "refreshUI") end
Мы сразу же отрезаем исполнение этой функции на клиенте через if onClient(). Вообще, причина, почему этот мод должен отрабатывать и на сервере и на клиенте заключается в том, что при прыжке в другую систему, игра сильно «перезагружает контекст», и все торги, что мы запомнили на клиенте просто пропадают из памяти. Далее, мы отнимаем наши текущие деньги от того, что запоминали в инициализаторе. Также, мы делим это значение на количество купленного или проданного через переменную delta. Используя специальные функции :getValue() и :setValue(), мы можем сохранять в игру любые данные. То есть, мы достаем данные из уже существующей таблицы истории, дополняем ее, и сохраняем обратно. Вызов invokeClientFunction дернет функцию refreshUI на клиенте (о ней чуть позже). Мы сразу же отрезаем собираемый лут с нулевой ценой.
function TheUI.onResourcesChanged(playerIndex) local player = Player(playerIndex) if not player then return end lastMoney = player.money end
Еще нам понадобится коллбек onResourcesChanged — он будет тригериться каждый раз, когда у игрока меняется баланс как денежный, так и по ресурсам. Так, в промежутках между покупками игрок может потратить средства на что-то еще помимо торгов. Это будет постоянно синхронизировать нашу переменную баланса игрока с его реальным балансом.
function serializeHistory(history) local parts = {} for _, entry in ipairs(history) do table.insert(parts, entry[1] .. ":" .. tostring(entry[2])) end return table.concat(parts, "|") end function deserializeHistory(raw) local history = {} if not raw or raw == "" then return history end for entry in raw:gmatch("[^|]+") do local key, value = entry:match("(.+):(.+)") if key and value then table.insert(history, {key, tonumber(value)}) end end return history end
Также нам понадобятся вот такие сериализатор и десериализатор табличных данных, так как из-за того, что мы работаем с сырой Lua-шной таблицей, в нее нельзя запихнуть любые данные кроме строк, цифр и boolean. То есть, при сохранении сериализируем, а при подгрузке десериалиируем.
function TheUI.refreshUI() if not list then return end list:clear() local raw = Player():getValue("HistoryTable") or "" local history = deserializeHistory(raw) local white = ColorRGB(1, 1, 1) for i, v in ipairs(history) do list:addRow(v[1]) list:setEntry(0, i - 1, v[1], false, false, white) list:setEntry(1, i - 1, string.format("%+.0f", v[2]), false, false, white) end end
Данная функция достанет данные из таблицы истории и полностью обновит таблицу через переменную list, которую мы видели во фронтовой функции buildTabs(). Из-за string.format(%+.0f...) перед цифрой поставится + если игрок что-то продал, и - если что-то купил.
function TheUI.clearHistory() local player = Player(callingPlayer) if not player then return end player:setValue("HistoryTable", "") invokeClientFunction(player, "refreshUI") end callable(TheUI, "clearHistory") function TheUI.onClearClicked() list:clear() invokeServerFunction("clearHistory") end
Это код для кнопки «CLEAR». Здесь мы просто опустошаем сохраненные данные и вызываем refreshUI на клиенте. callable сделает функцию clearHistory вызываемой с клиента через invokeServerFunction.
function TheUI.onShowWindow() TheUI.refreshUI() end function TheUI.interactionPossible(playerIndex, option) return true end
Эти функции приходят к нам из API. onShowWindow будет каждый раз рефрешить таблицу, как только игрок взаимодействует с модом. А вот interactionPossible используется для взаимодействия игрока с модом как таковым. Без return true внутри мод попросту никак не будет взаимодействовать с игроком (по крайней мере, я так это понимаю).
❯ Результат

Полный код мода из tradinghistory.lua есть под спойлером:
tradinghistory.lua
local ui local window local tabs local list -- namespace TheUI TheUI = {} -- BACKEND: local lastMoney = 0 function TheUI.initialize() local player = Player() lastMoney = player.money Entity():registerCallback("onCargoChanged", "onCargoChanged") print("registered callback for cargo price tracking") Player():registerCallback("onResourcesChanged", "onResourcesChanged") print("registered callback for resources and money tracking") end function TheUI.onResourcesChanged(playerIndex) local player = Player(playerIndex) if not player then return end lastMoney = player.money end -- SERVER SIDE: handles cargo change and saves data function TheUI.onCargoChanged(objectIndex, delta, good) if onClient() then return end local player = Player() if not player then return end local currentMoney = player.money local moneySpent = lastMoney - currentMoney local exactPricePaid = 0 exactPricePaid = -(moneySpent / delta) * (delta < 0 and -1 or 1) lastMoney = currentMoney -- avoid adding collected loot if exactPricePaid == 0 then return end -- save to player storage (server side only) local raw = player:getValue("HistoryTable") or "" local history = deserializeHistory(raw) table.insert(history, {good.name, exactPricePaid}) player:setValue("HistoryTable", serializeHistory(history)) -- notify client to refresh UI invokeClientFunction(player, "refreshUI") end -- SERVER SIDE: clear history function TheUI.clearHistory() local player = Player(callingPlayer) if not player then return end player:setValue("HistoryTable", "") invokeClientFunction(player, "refreshUI") end callable(TheUI, "clearHistory") -- FRONTEND: function TheUI.initUI() ui = ScriptUI(Entity()) local res = getResolution() local size = vec2(600, 400) window = ui:createWindow(Rect(res * 0.5 - size * 0.5, res * 0.5 + size * 0.5)) window.caption = "Trade History" window.showCloseButton = true window.moveable = true tabs = window:createTabbedWindow(Rect(vec2(10, 10), vec2(590, 390))) buildTabs() ui:registerWindow(window, "Trade History", 1) end function buildTabs() local tab = tabs:createTab("Info", "data/textures/icons/info.png", "Info") local margin = 3 local buttonHeight = 40 local spacing = 10 local size = tab.size local listRect = Rect( vec2(margin, margin), vec2(size.x - margin, size.y - margin - buttonHeight - spacing) ) list = tab:createListBoxEx(listRect) list.columns = 2 local res = getResolution() list.rowHeight = math.floor(res.y * 0.02) list:setColumnWidth(0, listRect.width * 0.6) list:setColumnWidth(1, listRect.width * 0.2) local buttonRect = Rect( vec2(margin, size.y - buttonHeight), vec2(size.x - margin, size.y - 10) ) local btn = tab:createButton(buttonRect, "Clear", "onClearClicked") end function TheUI.refreshUI() if not list then return end list:clear() local raw = Player():getValue("HistoryTable") or "" local history = deserializeHistory(raw) local white = ColorRGB(1, 1, 1) for i, v in ipairs(history) do list:addRow(v[1]) list:setEntry(0, i - 1, v[1], false, false, white) list:setEntry(1, i - 1, string.format("%+.0f", v[2]), false, false, white) end end function TheUI.onShowWindow() TheUI.refreshUI() end function TheUI.onClearClicked() list:clear() invokeServerFunction("clearHistory") end function TheUI.interactionPossible(playerIndex, option) return true end -- HELPERS -- serialize function serializeHistory(history) local parts = {} for _, entry in ipairs(history) do table.insert(parts, entry[1] .. ":" .. tostring(entry[2])) end return table.concat(parts, "|") end -- deserialize function deserializeHistory(raw) local history = {} if not raw or raw == "" then return history end for entry in raw:gmatch("[^|]+") do local key, value = entry:match("(.+):(.+)") if key and value then table.insert(history, {key, tonumber(value)}) end end return history end
А вот сам мод доступен в моем воркшопе Стима, и я буду очень рад если кто-то из читателей его протестирует.
И, на этом все! Хоть и на серверах довольно мало народу, я могу с уверенностью сказать, что игра сильно оправдала мои космосимные ожидания. Печалит только один факт — не на всех станциях можно продать купленный товар, как например в Port Roayle. То есть, если что-то купил, с этим можно очень долго возиться.
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩