Meta
Crie um smartphone pronto para produção no jogo FiveM usando NUI + React. Você criará um recurso, conectará eventos QBCore/ESX, persistirá dados no MySQL e enviará uma interface de usuário fluida que respeite os orçamentos de desempenho.
Pré-requisitos
- Um servidor FiveM em execução com txAdmin e MySQL (oxmysql ou mysql-async).
- Node.js 18+ e pnpm ou npm no seu PC de desenvolvimento.
- Uma estrutura instalada: qbNúcleo ou ESX.
- Bibliotecas recomendadas: boi_lib (retornos de chamada, notificações), inventário de bois (opcional para item de telefone), boi_alvo (opcional para interações mundiais).
- Conhecimento básico de React.
Documentos
- Visão geral do Cfx.re NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/
- Retornos de chamada NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/nui-callbacks/
- EnviarNUIMessage – https://docs.fivem.net/docs/scripting-reference/runtimes/lua/functions/SendNUIMessage/
- Depurar ferramentas de desenvolvimento NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/full-screen-nui/
- Funções do servidor QBCore – https://docs.qbcore.org/qbcore-documentation/qb-core/server-function-reference
- ESX
RegistrarItemUtilizável– https://docs.esx-framework.org/en/esx_core/es_extended/server/functions - retornos de chamada ox_lib – https://overextended.dev/ox_lib
Leitura interna (FiveMX)
- Resmon & desempenho – https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Centro de desempenho – https://fivemx.com/performance/
- Visão geral do mercado de scripts de telefone – https://fivemx.com/phone-scripts/
Arquitetura
- Recurso
meu_telefonecomfxmanifest.lua,cliente,servidor, einterface do usuáriopacote. - Interface do usuário: Aplicativo React criado com Vite em
/ui/dist. NUI fala com Lua viapostar mensagem+RegistrarNUICallback. - Dados: Tabelas MySQL para
contatos telefônicos,mensagens telefônicas,ligações telefônicas. - Cola para estrutura: QBCore ou O manipulador utilizável de itens ESX alterna o telefone e os retornos de chamada do servidor carregam/salvam dados.
Fluxo de eventos
- O jogador pressiona a tecla ou usa o item telefone → 2)
SetNuiFocus(verdadeiro, verdadeiro)eEnviarNUIMessage({ ação = 'abrir' })→ 3) React mostra UI → 4) UI solicita dados viabuscar('https://meu_telefone/xyz')(NUI) → 5)RegistrarNUICallback('xyz', ...)executa no cliente/servidor → 6) O servidor lê/grava no banco de dados → 7) A resposta retorna para a interface do usuário → 8) Feche o telefone e libere o foco.
Etapa 1 — Estruturar o recurso
Layout de pasta
recursos/ [local]/ my_phone/ fxmanifest.lua client/ main.lua server/ main.lua ui/ index.html src/ main.tsx App.tsx api.ts styles.css
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
ui_page 'ui/dist/index.html'
files { 'ui/dist/**' }
client_scripts { 'client/main.lua' }
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua'
}
lua54 'yes'
Etapa 2 — Criar o React NUI
Inicializar um aplicativo Vite React dentro meu_telefone/ui e construir para interface do usuário/dist.
cd meu_telefone/ui pnpm criar vite@latest . --template react-ts pnpm i
Configuração do Vite (garantir que os ativos cheguem em distância)
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist', emptyOutDir: true },
base: ''
})
Ponte NUI
// src/api.ts
export async function nui(event: string, data?: unknown): Promise {
const res = await fetch(`https://my_phone/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data ?? {})
})
return await res.json()
}
Monte React + ouvinte de mensagens
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render()
window.addEventListener('message', (e) => {
if (e.data?.action === 'open') document.body.classList.add('open')
if (e.data?.action === 'close') document.body.classList.remove('open')
})
Interface de usuário básica
// src/App.tsx
import { useEffect, useState } from 'react'
import { nui } from './api'
type Contact = { id: number; name: string; number: string }
export default function App() {
const [contacts, setContacts] = useState<Contact[]>([])
const [visible, setVisible] = useState(false)
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.action === 'open') {
setVisible(true)
nui<Contact[]>('contacts:list').then(setContacts)
}
if (e.data?.action === 'close') setVisible(false)
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
if (!visible) return null
return (
<div className="phone">
<header>Phone</header>
<section>
{contacts.map(c => (
<div key={c.id} className="row">
<div>{c.name}</div>
<div>{c.number}</div>
</div>
))}
</section>
<footer>
<button onClick={() => nui('ui:close')}>Close</button>
</footer>
</div>
)
}
índice.html
<!doctype html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>meu_telefone</title> <link rel="stylesheet" href="/src/styles.css" /> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
Crie a interface do usuário:
construção pnpm
Etapa 3 — Cliente: abrir/fechar, foco NUI, retornos de chamada
-- client/main.lua
local open = false
local function openPhone()
if open then return end
open = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end
local function closePhone()
if not open then return end
open = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
-- Keybind (F1 example)
RegisterCommand('myphone', function()
if open then closePhone() else openPhone() end
end)
RegisterKeyMapping('myphone', 'Toggle Phone', 'keyboard', 'F1')
-- NUI → game callbacks
RegisterNUICallback('ui:close', function(_, cb)
closePhone()
cb({ ok = true })
end)
-- list contacts asks the server
RegisterNUICallback('contacts:list', function(_, cb)
lib.callback('my_phone:server:getContacts', false, function(rows)
cb(rows)
end)
end)
Dica: habilite o NUI devtools no console de jogo com
nui_devTools. Abrirhttp://localhost:13172no seu navegador Chromium para inspecionar a interface do usuário.
Etapa 4 — Servidor: esquema de banco de dados + retornos de chamada
SQL (MySQL)
CREATE TABLE IF NOT EXISTS phone_contacts ( id INT AUTO_INCREMENT PRIMARY KEY, citizenid VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL, number VARCHAR(32) NOT NULL, INDEX(citizenid) ); CREATE TABLE IF NOT EXISTS phone_messages ( id BIGINT AUTO_INCREMENT PRIMARY KEY, owner VARCHAR(64) NOT NULL, peer VARCHAR(64) NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(owner), INDEX(peer) );
Servidor com oxmysql + ox_lib
-- server/main.lua
local QBCore = exports['qb-core'] and exports['qb-core']:GetCoreObject()
ESX = ESX or nil
if not QBCore then
TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end)
end
-- Load contacts for the logged-in character
lib.callback.register('my_phone:server:getContacts', function(source)
local citizenid
if QBCore then
local Player = QBCore.Functions.GetPlayer(source)
citizenid = Player and Player.PlayerData.citizenid
else
local xPlayer = ESX.GetPlayerFromId(source)
citizenid = xPlayer and xPlayer.identifier
end
if not citizenid then return {} end
local rows = MySQL.query.await('SELECT id, name, number FROM phone_contacts WHERE citizenid = ?', { citizenid })
return rows or {}
end)
-- Save a contact
lib.callback.register('my_phone:server:addContact', function(source, contact)
if type(contact) ~= 'table' then return { ok = false } end
local name, number = contact.name, contact.number
if not name or not number then return { ok = false } end
local citizenid
if QBCore then
local Player = QBCore.Functions.GetPlayer(source)
citizenid = Player and Player.PlayerData.citizenid
else
local xPlayer = ESX.GetPlayerFromId(source)
citizenid = xPlayer and xPlayer.identifier
end
if not citizenid then return { ok = false } end
MySQL.insert.await('INSERT INTO phone_contacts (citizenid, name, number) VALUES (?, ?, ?)', { citizenid, name, number })
return { ok = true }
end)
Etapa 5 — Integração do Framework (item + permissões)
qbNúcleo
Adicione um item de telefone utilizável e alterne a interface do usuário quando usado.
-- server/main.lua (QBCore only)
if QBCore then
QBCore.Functions.CreateUseableItem('phone', function(src, item)
TriggerClientEvent('my_phone:client:toggle', src)
end)
end
-- client/main.lua
RegisterNetEvent('my_phone:client:toggle', function()
if IsPauseMenuActive() then return end
if IsPedInAnyVehicle(PlayerPedId(), false) then -- optional rule
-- show a notification via ox_lib
lib.notify({ title = 'Phone', description = 'No phone while driving.', type = 'error' })
return
end
if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end
end)
ESX
-- server/main.lua (ESX only)
if ESX and not QBCore then
ESX.RegisterUsableItem('phone', function(playerId)
TriggerClientEvent('my_phone:client:toggle', playerId)
end)
end
Se você usar inventário de bois, crie o item lá e confie em seus manipuladores utilizáveis. Você ainda pode acionar o mesmo evento do cliente.
Etapa 6 — Principais recursos
Implemente pequenas fatias e envie incrementalmente.
Contatos
- Chamadas de interface do usuário
contatos:lista→ o servidor retorna linhas. - Adicionar formulário “Adicionar contato” → ligar
adicionar contato. - Adicionar “Remover contato” → servidor exclui por
eu iacom verificação de propriedade do cidadão.
Mensagens (SMS)
- Mesa
mensagens telefônicasdono de lojas, colega, corpo. - A IU abre um chat, faz chamadas
mensagens:listaemensagens:enviar. - O servidor insere uma mensagem e, opcionalmente, emite um evento do cliente para o peer se estiver online.
Esboço do servidor
lib.callback.register('my_phone:server:messages:list', function(source, peer)
local cid = GetCitizenId(source)
return MySQL.query.await('SELECT * FROM phone_messages WHERE owner=? AND peer=? ORDER BY id DESC LIMIT 200', { cid, peer }) or {}
end)
RegisterNetEvent('my_phone:server:messages:send', function(peer, body)
local src = source
local cid = GetCitizenId(src)
if type(body) ~= 'string' or #body == 0 or #body > 500 then return end
MySQL.insert.await('INSERT INTO phone_messages (owner, peer, body) VALUES (?, ?, ?)', { cid, peer, body })
TriggerClientEvent('my_phone:client:messages:push', src, peer, body)
-- optional: find target player by phone number and push live event
end)
O cliente recebe
RegisterNetEvent('my_phone:client:messages:push', function(peer, body)
SendNUIMessage({ action = 'message:new', peer = peer, body = body })
end)
Chamadas (MVP opcional)
- Armazene apenas registros de chamadas. O Real Audio usa seu plugin de voz (pma-voice, mumble, SaltyChat) e está fora deste MVP.
- Adicione o teclado da interface → ao discar, registre uma chamada efetuada; ao atender, registre uma chamada recebida. Você pode integrar posteriormente com a API de um plugin de voz.
Etapa 7 — Segurança, UX, desempenho
Segurança
- Nunca confie na entrada NUI. Valide os tipos e o comprimento no servidor.
- Verifique a propriedade de cada consulta com
identidade de cidadãoouidentificador. - Evite expor identificadores a outros clientes. Use retransmissões de servidor.
UX
- Cancele o uso do telefone enquanto estiver caído, algemado ou dirigindo, se as regras do seu servidor exigirem.
- Mantenha a interface do usuário ágil. Use atualizações otimistas e reconcilie na confirmação do servidor.
Desempenho
- Mantenha o NUI ocioso. Evite loops setInterval no React. Use efeitos e eventos.
- Mantenha os pacotes pequenos. Carregue telas pesadas com preguiça. Envie ativos compactados.
- Use o Resmon para obter um orçamento com média de menos de 0,01–0,02 ms. Consulte o guia FiveMX no link acima.
Etapa 8 — Teste e depuração
- Iniciar recurso em
servidor.cfgantes de scripts dependentes.
garantir meu_telefone
- No jogo, pressione F8 → executar
nui_devTools→ abertohttp://localhost:13172e escolha sua página NUI. - Inspecione a aba de rede. Cada chamada NUI → Lua é acionada
https://my_phone/<name>pontos finais. - Usar
/meu telefonecomando e confirmar alternância de foco. - Execute o Resmon e verifique se a CPU permanece baixa enquanto o telefone está aberto e fechado.
Etapa 9 — Embalagem e atualizações
- Comprometer-se
interface do usuário/fonte einterface do usuário/dist/construir. - Em CI, execute
pnpm --filter construção de interface do usuárioe somente naviodistânciaem lançamentos. - Controle a versão das suas migrações de SQL. Nunca descarte dados de usuários sem fazer backups.
Etapa 10 — Extensões que você pode adicionar em seguida
- Bancário: link para o recurso bancário do seu servidor; exponha saldo e transferências.
- Tweets/Anúncios: feed global com limites de taxa e moderação.
- Mercado: listagens com custódia.
- Aplicativos de emprego: ganchos de MDT da polícia/EMS.
- Fotos: integração de captura de tela via endpoint do servidor, não URLs de dados.
- Configurações: temas dinâmicos, toques, planos de fundo.
Solução de problemas
O telefone abre atrás do menu de pausa
Desabilite as verificações durante a pausa e reabra quando a atividade retornar.
O retorno de chamada NUI não dispara
- Garantir
RegisterNUICallback('evento', ...)os nomes correspondem ao caminho de busca da IU. - Confirmar
versão_fxécerúleoepágina_uiaponta paraui/dist/index.html. - Verifique o console F8 para erros CORS ou JSON.
Itens não utilizáveis
- QBCore: confirmar
QBCore.Funções.CriarItemUtilizávelcorre etelefoneexiste na sua lista de itens. - ESX: confirmar
ESX.RegisterUsableItem('telefone', ...)registros após cargas de estoque.
Erros de banco de dados
- Certifique-se de que o oxmysql tenha iniciado antes deste recurso.
- Verifique os tamanhos das colunas e as codificações para nomes Unicode.
Trechos de referência (copiar e colar)
Ajudante
function GetCitizenId(source) if QBCore then local P = QBCore.Functions.GetPlayer(source) return P and P.PlayerData.citizenid else local xP = ESX.GetPlayerFromId(source) return xP and xP.identifier end end
Adicionar contato da IU
// UI
async function addContact(name: string, number: string) {
const res = await nui('contacts:add', { name, number })
if (res.ok) {
const next = await nui('contacts:list')
// update state
}
}
-- client
RegisterNUICallback('contacts:add', function(data, cb)
lib.callback('my_phone:server:addContact', false, function(resp)
cb(resp)
end, data)
end)
O que você construiu
- Um MVP de telefone focado em contatos e mensagens.
- Uma ponte NUI limpa que funciona em ambas as estruturas.
- Uma camada de banco de dados que você pode expandir com segurança.
Envie, meça o desempenho e repita.
Leitura adicional
- FiveMX Resolução e otimização: https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Comparação de voz (escolha sua pilha): https://fivemx.com/fivem-voice-mumble-saltychat-pma-voice-guide/
- Telefones existentes para inspiração:
- lb‑phone v2: https://fivemx.com/lb-phone-v2/
- Smartphone Quasar: https://fivemx.com/phone-scripts/
- GCPhone: https://fivemx.com/free/gcphone/
- Z-Telefone: https://fivemx.com/free/z-phone/
Meta
- Resposta de destino: ≤0,02 ms em média enquanto ocioso.
- Orçamento do pacote: ≤250 KB compactados para MVP.
- FPS da interface do usuário: 60.
Recursos relacionados ao FiveMX
Use estas seções para comparar categorias relacionadas do FiveM e configurar recursos antes de alterar um servidor em produção.