To jest adapter FiveM Framework – dla skrypterów. Dostarcz jeden zasób, który działa na ESX, QBCore, oraz QBOX poprzez izolowanie wywołań specyficznych dla frameworka za cienkim adapterem. Umieść plik shared/fw.lua oraz adaptery per‑framework poniżej w dowolnym zasobie, wywołaj stabilny kontrakt interfejsu (FW.Player, FW.Job, FW.Money, FW.Inv, FW.Events), a logika biznesowa pozostanie niezależna od frameworka. Mała macierz testowa z atrapami wyłapuje niezgodności przed wdrożeniem.
Dlaczego Adapter?
Różnice między frameworkami skupiają się wokół tych samych szwów:
- Rdzeń dostęp (ESX
getSharedObject, QBCoreGetCoreObject, tylko eksporty QBOX) - Model gracza (xPlayer vs Player/PlayerData)
- Identyfikatory (license/steam vs citizenid)
- Pieniądze i ekwipunek API
- Nazwy zdarzeń podczas ładowania/logowania/aktualizacji pracy
A ujednolicony interfejs utrzymuje te szwy poza logiką gry. Zamieniasz adapter, nie kod bazowy.
BTW: Możesz użyć naszego napisanego adaptera tutaj, za darmo:
Jak używać (Drop‑in)
Drzewo (zalecane):
my-resource/ ├─ fxmanifest.lua ├─ shared/ │ ├─ adapters/ │ │ ├─ esx.lua │ │ ├─ qb.lua │ │ └─ qbox.lua │ └─ fw.lua ├─ server/ │ └─ main.lua └─ client/ └─ main.lua
fxmanifest.lua (załaduj adaptery najpierw, potem fw.lua aby detekcja mogła się powiązać):
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
shared_scripts {
'shared/adapters/*.lua',
'shared/fw.lua'
}
client_scripts {
'client/*.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua', -- optional: if you use SQL
'server/*.lua'
}
W swoim kodzie (serwer lub klient):
-- use the stable interface everywhere local src = source local p = FW.Player.getBySrc(src) local job = FW.Job.getName(p) FW.Money.add(p, 'cash', 250, 'delivery-bonus') FW.Inv.addItem(p, 'water', 1) FW.Events.notify(src, 'Job bonus paid.', 'success')
Jedynym symbolem, od którego zależysz, jest
FW. Wszystko inne jest wewnętrzne dla adaptery.
Kontrakt interfejsu (stabilna powierzchnia)
Cel projektu: Mały, jawny, udokumentowany. Są to funkcje, na których możesz polegać w różnych frameworkach.
FW.meta
name() -> 'esx'|'qbcore'|'qbox'has(resourceName: string) -> boolean(zasób uruchomiony?)
FW.Player
getBySrc(src: number) -> any(uchwyt gracza frameworka)getStateId(p) -> string(ESX: identyfikator; QB/QBOX: citizenid)getServerId(p) -> number(identyfikator numeryczny)getName(p) -> string
FW.Job
getName(p) -> stringgetGrade(p) -> number|stringonChange(handler(src, oldJob, newJob))(wywoływane, gdy zmienia się praca, jeśli jest wykrywalna)
FW.Money
get(p, account: 'cash'|'bank'|'black_money'?) -> numberadd(p, account, amount: number, reason?: string)remove(p, account, amount: number, reason?: string)
FW.Inv (najlepsza możliwa próba; patrz Uwagi)
addItem(p, name: string, count: number, metadata?: table) -> booleanremoveItem(p, name: string, count: number, metadata?: table) -> boolean
Uwaga do ekwipunku: serwery się różnią (qb-inventory, ox_inventory, qs‑inventory, itp.). Domyślna implementacja używa ekwipunku frameworka, gdy jest dostępny, i wraca do
ox_inventoryjeśli wykryto.
FW.Events
notify(target: number, msg: string, type?: 'info'|'success'|'error')onPlayerLoaded(handler(src))(najlepsza możliwa próba, z zastępczym rozwiązaniem przezplayerJoining)
Adaptery typu "drop-in" (kopiuj/wklej)
Są to pragmatyczne ustawienia domyślne. Jeśli Twój fork się różni (szczególnie dla QBOX), dostosuj kilka oznaczonych komentarzy.
shared/fw.lua
-- framework bridge bootstrap
FW = FW or {}
local function started(name)
local st = GetResourceState(name)
return st == 'started' or st == 'starting'
end
local which
if started('qbx_core') then which = 'qbox'
elseif started('qb-core') then which = 'qbcore'
elseif started('es_extended') then which = 'esx' end
if which == 'qbcore' then
FW = Adapters.qb()
elseif which == 'qbox' then
FW = Adapters.qbox()
elseif which == 'esx' then
FW = Adapters.esx()
else
error('[FW] No supported framework found (es_extended / qb-core / qbx_core).')
end
-- tiny helpers common to all adapters
function FW.meta.has(res)
return started(res)
end
shared/adapters/esx.lua
Adapters = Adapters or {}
Adapters.esx = function()
local ESX = exports['es_extended']:getSharedObject()
local M = {
meta = { name = function() return 'esx' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return ESX.GetPlayerFromId(src) end
function M.Player.getStateId(p) return p.identifier end
function M.Player.getServerId(p) return p.source end
function M.Player.getName(p) return p.getName and p.getName() or GetPlayerName(p.source) end
-- Job
function M.Job.getName(p) return (p.getJob and p.getJob().name) or (p.job and p.job.name) end
function M.Job.getGrade(p)
local j = p.getJob and p.getJob() or p.job
return j and (j.grade or (j.grade and j.grade.grade))
end
function M.Job.onChange(handler)
-- ESX fires when job changes (commonly 'esx:setJob')
RegisterNetEvent('esx:setJob', function(job)
local src = source
handler(src, nil, job and job.name)
end)
end
-- Money
local function norm(account) return account == 'cash' and 'money' or account end
function M.Money.get(p, account)
account = norm(account)
if account == 'money' then return p.getMoney() end
local acc = p.getAccount and p.getAccount(account)
return acc and acc.money or 0
end
function M.Money.add(p, account, amount)
account = norm(account)
if account == 'money' then p.addMoney(amount) else p.addAccountMoney(account, amount) end
end
function M.Money.remove(p, account, amount)
account = norm(account)
if account == 'money' then p.removeMoney(amount) else p.removeAccountMoney(account, amount) end
end
-- Inventory (ESX native, with ox fallback)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count) p.addInventoryItem(name, count); return true end
function M.Inv.removeItem(p, name, count) p.removeInventoryItem(name, count); return true end
end
-- Events
function M.Events.notify(target, msg, kind)
kind = kind or 'info'
-- Implement your UI notify event here. Example placeholder:
TriggerClientEvent('fw:notify', target, msg, kind)
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('esx:playerLoaded', function(_)
handler(source)
end)
end
return M
end
shared/adapters/qb.lua (QBCore)
Adapters = Adapters or {}
Adapters.qb = function()
local QBCore = exports['qb-core']:GetCoreObject()
local M = {
meta = { name = function() return 'qbcore' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return QBCore.Functions.GetPlayer(src) end
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- QBCore client event relays job update; mirror serverside via simple relay if needed.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason) p.Functions.AddMoney(account, amount, reason or 'fw') end
function M.Money.remove(p, account, amount, reason) p.Functions.RemoveMoney(account, amount, reason or 'fw') end
-- Inventory (qb-inventory or ox)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
shared/adapters/qbox.lua (QBOX / qbx_core)
Adapters = Adapters or {}
Adapters.qbox = function()
-- QBOX typically exposes functions via exports only.
-- If your fork also ships a GetCoreObject, swap accordingly.
local QBX = exports['qbx_core']
local M = {
meta = { name = function() return 'qbox' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player (QBOX uses Player with PlayerData similar to QBCore)
function M.Player.getBySrc(src) return QBX:GetPlayer(src) end -- adjust if your API differs
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- Some QBOX builds forward QBCore job events; if not, wire your own when setting jobs.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason)
if p.Functions and p.Functions.AddMoney then p.Functions.AddMoney(account, amount, reason or 'fw')
else QBX:AddMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
function M.Money.remove(p, account, amount, reason)
if p.Functions and p.Functions.RemoveMoney then p.Functions.RemoveMoney(account, amount, reason or 'fw')
else QBX:RemoveMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
-- Inventory (ox preferred on many QBOX servers)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
-- fall back to qb-style if present
if p and p.Functions and p.Functions.AddItem then
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
else
function M.Inv.addItem() return false end
function M.Inv.removeItem() return false end
end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
-- Some QBOX builds reuse QBCore load events; if yours differs, relay from your login logic.
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
Przykłady użycia
1) Wypłacanie premii za pracę
RegisterNetEvent('myres:payBonus', function()
local src = source
local p = FW.Player.getBySrc(src)
if not p then return end
if FW.Job.getName(p) == 'delivery' then
FW.Money.add(p, 'cash', 250, 'delivery-bonus')
FW.Events.notify(src, 'Bonus paid (+$250).', 'success')
else
FW.Events.notify(src, 'You are not on duty as Delivery.', 'error')
end
end)
2) Przyznanie ekwipunku z fallbackiem ox już obsłużone
local function giveStarter(src) local p = FW.Player.getBySrc(src) if p then FW.Inv.addItem(p, 'water', 2) end end FW.Events.onPlayerLoaded(giveStarter)
Katalog anty-wzorców (i poprawki)
| Antywzorzec | Dlaczego to gryzie | Napraw za pomocą adaptera |
|---|---|---|
Wbudowane na stałe kluczowe obiekty (ESX = exports['es_extended']:getSharedObject() rozsiane wszędzie) | Blokuje Cię w ESX, uciążliwe do migracji | Wywołuj tylko FW.*. Rozwiązanie serwera znajduje się w adapterze. |
Długoterminowe przechowywanie uchwytu gracza frameworka (np. zachowaj xPlayer na zawsze w tabeli) | Uchwyty mogą się przeterminować; odniesienia różnią się w zależności od frameworka | Pobierz ponownie przez FW.Player.getBySrc(src) kiedy działasz, lub buforuj przez getStateId klucz i ponowne rozwiązanie. |
Zakładając identyfikatory są takie same (ESX identifier vs QB/QBOX citizenid) | Niszczy relacje/migracje bazy danych | Używać FW.Player.getStateId(p) i tabela przejść podczas migracji. |
Bezpośrednie nazwy zdarzeń w logice biznesowej (esx:playerLoaded, QBCore:Server:PlayerLoaded) | Kruche na rozwidleniach | Subskrybuj przez FW.Events.onPlayerLoaded. |
| Założenia dotyczące mieszanych zapasów | Serwery często wymieniają ekwipunki | Używać FW.Inv.* który wykrywa ox_inventory najpierw, potem framework. |
| SQL schematy zamrożone do jednej struktury | accounts, identifier, itp. rozbiegają się | Użyj neutralnych kolumn (state_id, money_cash, money_bank) oraz pomocników migracji poniżej. |
SQL & Notatki dotyczące migracji identyfikatorów (szybki skrót)
- Klucz główny osoby:
- ESX →
identifier(licencja/Steam) - QB/QBOX →
citizenid
- ESX →
- Neutralny klucz w twoich tabelach:
state_id(string). SklepFW.Player.getStateId(p). - Pieniądze:
- ESX:
money(gotówka),accounts.bank,accounts.black_money - QB/QBOX:
PlayerData.money.cash|bank
- ESX:
- Minimalne przejście dla pieszych (jednorazowe uzupełnienie):
-- Example: populate your neutral key from ESX users UPDATE my_table t JOIN users u ON u.identifier = t.identifier SET t.state_id = u.identifier WHERE t.state_id IS NULL; -- Example: migrate to QB/QBOX where you have a mapping table esx_identifier→citizenid UPDATE my_table t JOIN id_map m ON m.esx_identifier = t.state_id SET t.state_id = m.citizenid WHERE m.citizenid IS NOT NULL;
Zachowaj przejście (
id_map) tylko podczas przejścia; przyszłe zapisy powinny zawsze używaćstate_id.
Macierz testowania i CI: Walidacja skryptu w różnych strukturach
Nie musisz uruchamiać pełnego serwera CFX w CI, aby wyłapać większość problemów z adapterami. Eksporty zastępcze i uruchom testy jednostkowe dla interfejsu kontraktu.
1) Minimalny test (Zatrzymany)
tests/fw_spec.lua
local function makeStub(framework)
_G.Adapters = {}
if framework == 'esx' then
_G.exports = { ['es_extended'] = { getSharedObject = function()
return {
GetPlayerFromId = function(src)
return {source = src, identifier = 'license:abc', getMoney = function() return 100 end,
addMoney = function() end, removeMoney = function() end,
getJob = function() return {name='mechanic', grade=2} end,
addAccountMoney=function() end, removeAccountMoney=function() end,
addInventoryItem=function() end, removeInventoryItem=function() end,
getName=function() return 'Alex ESX' end }
end
}
end } }
_G.GetResourceState = function(n) return n=='es_extended' and 'started' or 'missing' end
dofile('shared/adapters/esx.lua')
elseif framework == 'qbcore' then
_G.exports = { ['qb-core'] = { GetCoreObject = function()
return { Functions = { GetPlayer=function(src)
return { PlayerData={source=src,citizenid='CITZ123',job={name='mechanic',grade=2},
money={cash=100,bank=500},charinfo={firstname='Alex',lastname='QB'}},
Functions={AddMoney=function() end, RemoveMoney=function() end, AddItem=function() return true end, RemoveItem=function() return true end} }
end } }
end } }
_G.GetResourceState = function(n) return n=='qb-core' and 'started' or 'missing' end
dofile('shared/adapters/qb.lua')
elseif framework == 'qbox' then
_G.exports = { ['qbx_core'] = setmetatable({}, { __index = function()
return function(name) end
end }) }
_G.GetResourceState = function(n) return n=='qbx_core' and 'started' or 'missing' end
dofile('shared/adapters/qbox.lua')
end
dofile('shared/fw.lua')
end
describe('FW contract', function()
it('resolves player and money (esx)', function()
makeStub('esx')
assert.are.equal('esx', FW.meta.name())
local p = FW.Player.getBySrc(1)
assert.are.equal('license:abc', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
it('resolves player and money (qbcore)', function()
makeStub('qbcore')
assert.are.equal('qbcore', FW.meta.name())
local p = FW.Player.getBySrc(2)
assert.are.equal('CITZ123', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
end)
2) GitHub Akcje (luacheck + busted)
.github/workflows/lua.yml
name: Lua CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
lua: [ '5.4' ]
steps:
- uses: actions/checkout@v4
- name: Install Lua & LuaRocks
uses: leafo/gh-actions-lua@v10
with: { luaVersion: ${{ matrix.lua }} }
- name: Install rocks
uses: leafo/gh-actions-luarocks@v4
- run: luarocks install luacheck
- run: luarocks install busted
- name: Lint
run: luacheck . --no-color --codes
- name: Test
run: busted -v
.luacheckrc (domyślny)
std = 'lua54'
unused_args = false
max_line_length = 140
ignore = { '211', '212' } -- adjust for your style
Aby przeprowadzić pełne testy integracyjne, uruchom swój serwer deweloperski raz i przetestuj go za pomocą niewielkiego zestawu poleceń. Stuby CI wystarczą, aby wyłapać powierzchowne błędy.
Lista kontrolna implementacji
- Wrzuć
shared/adapters/*.luaIshared/fw.luado twojego zasobu - Zastąp wszystkie bezpośrednie wywołania ESX/QBCore/QBOX w swoim kodzie za pomocą
FW.* - Zachowaj tylko jeden klucz trwałości:
state_idw twoich tabelach - Skonfiguruj preferencje ekwipunku (domyślnie najpierw ox)
- Dodaj CI (luacheck + busted) i minimalny test dla każdego wywołania, którego używasz
- Dokumentuj wszelkie lokalne odchylenia (zdarzenia specyficzne dla forka) na górze pliku adaptera
Rozszerzona Powierzchnia (opcjonalne dodatki)
FW.Duty.set(p, true|false)– zawiń przełączniki dyżuruFW.Permissions.has(src, aceOrGroup)– centralizacja sprawdzania administratorów/grupFW.Vehicle.spawn(model, coords)– ukryj pomocników tworzenia obiektów frameworka
Zachowaj rdzeń contract tiny; umieść opcjonalne pomocniki w osobnym module.
Szybka Tabela Mapowania Tri-way
| Zmartwienie | ESX | QBCore | QBOX (typowe) |
|---|---|---|---|
| Dostęp do rdzenia | exports['es_extended']:getSharedObject() | exports['qb-core']:GetCoreObject() | Brak globalnych; eksporty włączone qbx_core |
| Gracz według src | ESX.GetPlayerFromId(src) | QBCore.Functions.GetPlayer(src) | exports.qbx_core:GetPlayer(src) (dostosuj, jeśli forkowane) |
| Identyfikator | xPlayer.identifier | PlayerData.citizenid | PlayerData.citizenid |
| Dodaj pieniądze | addMoney / addAccountMoney | Functions.AddMoney | Functions.AddMoney Lub exports.qbx_core:AddMoney |
| Nazwa pracy | xPlayer.job.name | PlayerData.job.name | PlayerData.job.name |
| Zdarzenie załadowania gracza | esx:playerLoaded | QBCore:Server:PlayerLoaded | Często ponownie wykorzystuje zdarzenia QBCore; specyficzne dla forka |
W razie wątpliwości co do QBOX, sprawdź swój widelec
qbx_coreexports i odpowiednio okablować.
Uwagi końcowe
- Zachowaj adaptery nudny: brak efektów ubocznych, brak wywołań bazy danych.
- Traktuj frameworkowe uchwyty jako nieprzezroczysty; wyodrębnij to, czego potrzebujesz, poprzez umowę.
- Kiedy musisz odejść od klienta, skopiuj adapter, nie twoja logika biznesowa.
Czytaj dalej: Konwersja skryptów FiveM między ESX, QBCore i QBOX (Strona filarowa)
