Economize 20% com WELCOMEVer ofertas
Crie um aplicativo personalizado para celular no FiveM

Crie um aplicativo personalizado para celular no FiveM

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

  1. Um servidor FiveM em execução com txAdmin e MySQL (oxmysql ou mysql-async).
  2. Node.js 18+ e pnpm ou npm no seu PC de desenvolvimento.
  3. Uma estrutura instalada: qbNúcleo ou ESX.
  4. 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).
  5. Conhecimento básico de React.

Documentos

Leitura interna (FiveMX)


Arquitetura

  1. Recurso meu_telefone com fxmanifest.lua, cliente, servidor, e interface do usuário pacote.
  2. Interface do usuário: Aplicativo React criado com Vite em /ui/dist. NUI fala com Lua via postar mensagem + RegistrarNUICallback.
  3. Dados: Tabelas MySQL para contatos telefônicos, mensagens telefônicas, ligações telefônicas.
  4. 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

  1. O jogador pressiona a tecla ou usa o item telefone → 2) SetNuiFocus(verdadeiro, verdadeiro) e EnviarNUIMessage({ ação = 'abrir' }) → 3) React mostra UI → 4) UI solicita dados via buscar('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. Abrir http://localhost:13172 no 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

  1. Chamadas de interface do usuário contatos:lista → o servidor retorna linhas.
  2. Adicionar formulário “Adicionar contato” → ligar adicionar contato.
  3. Adicionar “Remover contato” → servidor exclui por eu ia com verificação de propriedade do cidadão.

Mensagens (SMS)

  1. Mesa mensagens telefônicas dono de lojas, colega, corpo.
  2. A IU abre um chat, faz chamadas mensagens:lista e mensagens:enviar.
  3. 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

  1. Nunca confie na entrada NUI. Valide os tipos e o comprimento no servidor.
  2. Verifique a propriedade de cada consulta com identidade de cidadão ou identificador.
  3. Evite expor identificadores a outros clientes. Use retransmissões de servidor.

UX

  1. Cancele o uso do telefone enquanto estiver caído, algemado ou dirigindo, se as regras do seu servidor exigirem.
  2. Mantenha a interface do usuário ágil. Use atualizações otimistas e reconcilie na confirmação do servidor.

Desempenho

  1. Mantenha o NUI ocioso. Evite loops setInterval no React. Use efeitos e eventos.
  2. Mantenha os pacotes pequenos. Carregue telas pesadas com preguiça. Envie ativos compactados.
  3. 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

  1. Iniciar recurso em servidor.cfg antes de scripts dependentes.
garantir meu_telefone
  1. No jogo, pressione F8 → executar nui_devTools → aberto http://localhost:13172 e escolha sua página NUI.
  2. Inspecione a aba de rede. Cada chamada NUI → Lua é acionada https://my_phone/<name> pontos finais.
  3. Usar /meu telefone comando e confirmar alternância de foco.
  4. Execute o Resmon e verifique se a CPU permanece baixa enquanto o telefone está aberto e fechado.

Etapa 9 — Embalagem e atualizações

  1. Comprometer-se interface do usuário/ fonte e interface do usuário/dist/ construir.
  2. Em CI, execute pnpm --filter construção de interface do usuário e somente navio distância em lançamentos.
  3. 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

  1. Bancário: link para o recurso bancário do seu servidor; exponha saldo e transferências.
  2. Tweets/Anúncios: feed global com limites de taxa e moderação.
  3. Mercado: listagens com custódia.
  4. Aplicativos de emprego: ganchos de MDT da polícia/EMS.
  5. Fotos: integração de captura de tela via endpoint do servidor, não URLs de dados.
  6. 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úleo e página_ui aponta para ui/dist/index.html.
  • Verifique o console F8 para erros CORS ou JSON.

Itens não utilizáveis

  • QBCore: confirmar QBCore.Funções.CriarItemUtilizável corre e telefone existe 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


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.