❯ Что за игра?

Больше всего этот продукт подходит для сравнения с серией игр 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-канале 

Комментарии (0)