Crea una aplicación móvil personalizada para el FiveM.

Crea una aplicación móvil personalizada para el FiveM.

Meta
Cree un teléfono inteligente listo para producción dentro del juego para FiveM usando NUI + React. Armará un recurso, conectará eventos QBCore/ESX, persistirá datos en MySQL y entregará un UI fluido que respete los presupuestos de rendimiento.


Prerrequisitos

  1. Un servidor FiveM en ejecución con txAdmin y MySQL (oxmysql o mysql-async).
  2. Node.js 18+ y pnpm o npm en su PC de desarrollo.
  3. Un framework instalado: QBCore o ESX.
  4. Librerías recomendadas: biblioteca de buey (callbacks, notificaciones), inventario_de_bueyes (opcional para el objeto teléfono), objetivo_buey (opcional para interacciones del mundo).
  5. Conocimientos básicos de React.

Documentos

Lectura interna (FiveMX)


Arquitectura

  1. Recurso my_phone con fxmanifest.lua, client, server, y ui bundle.
  2. Interfaz de usuario: Aplicación React construida con Vite en /ui/dist. NUI se comunica con Lua mediante postMessage + RegisterNUICallback.
  3. Datos: Tablas MySQL para phone_contacts, phone_messages, phone_calls.
  4. Framework glue:QBCore o El manejador de ítems usables ESX activa el teléfono, y los callbacks del servidor cargan/guardan datos.

Flujo de eventos

  1. El jugador presiona una tecla o usa el ítem del teléfono → 2) SetNuiFocus(true, true) y SendNUIMessage({ action = 'open' }) → 3) React muestra UI → 4) UI solicita datos mediante fetch('https://my_phone/xyz') (NUI) → 5) RegisterNUICallback('xyz', ...) se ejecuta en cliente/servidor → 6) El servidor lee/escribe la BD → 7) La respuesta regresa a UI → 8) Cerrar teléfono y liberar el foco.

Paso 1 — Crear la estructura del recurso

Diseño de carpetas

resources/
 [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'

Paso 2 — Crear el NUI de React

Inicializa una aplicación de Vite React dentro de my_phone/ui y compila en ui/dist.

cd my_phone/ui
pnpm create vite@latest . --template react-ts
pnpm i

Configuración de Vite (asegúrate de que los activos se coloquen en dist)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
 plugins: [react()],
 build: { outDir: 'dist', emptyOutDir: true },
 base: ''
})

Puente NUI

// src/api.ts
export async function nui<T>(event: string, data?: unknown): Promise<T> {
 const res = await fetch(`https://my_phone/${event}`, {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(data ?? {})
 })
 return await res.json()
}

Montar React + listener de mensajes

// 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(<App />)

window.addEventListener('message', (e) => {
 if (e.data?.action === 'open') document.body.classList.add('open')
 if (e.data?.action === 'close') document.body.classList.remove('open')
})

UI básico

// 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>my_phone</title>
 <link rel="stylesheet" href="/src/styles.css" />
 </head>
 <body>
 <div id="root"></div>
 <script type="module" src="/src/main.tsx"></script>
 </body>
</html>

Construye el UI:

pnpm build

Paso 3 — Cliente: abrir/cerrar, enfoque NUI, callbacks

-- 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)

Consejo: habilita las herramientas de desarrollo NUI en la consola del juego con nui_devTools. Abre http://localhost:13172 en tu navegador Chromium para inspeccionar el UI.


Paso 4 — Servidor: esquema de BD + callbacks

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 con 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)

Paso 5 — Integración del framework (ítem + permisos)

QBCore

Agrega un ítem de teléfono usable y alterna el UI cuando se use.

-- 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

Si usas inventario_de_bueyes, crea el ítem allí y confía en sus manejadores usables. Aún puedes activar el mismo evento de cliente.


Paso 6 — Funcionalidades principales

Implementa pequeñas partes y envía de forma incremental.

Contactos

  1. Llamadas UI contacts:list → solicita al servidor.
  2. Agregar formulario “Agregar contacto” → llamar addContact.
  3. Agregar “Eliminar contacto” → el servidor elimina por id con verificación de propiedad del ciudadano.

Mensajes (SMS)

  1. Mesa phone_messages almacena propietario, interlocutor, cuerpo.
  2. UI abre un chat, llama messages:list y messages:send.
  3. El servidor inserta un mensaje y, opcionalmente, emite un evento del cliente al par si está en línea.

Esquema del 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)

Recepción del cliente

RegisterNetEvent('my_phone:client:messages:push', function(peer, body)
 SendNUIMessage({ action = 'message:new', peer = peer, body = body })
end)

Llamadas (MVP opcional)

  • Solo almacena registros de llamadas. El audio real usa tu plugin de voz (pma-voice, mumble, SaltyChat) y queda fuera de este MVP.
  • Añade un teclado UI → al marcar, registra una llamada saliente; al contestar, registra una entrante. Puedes integrarlo después con la API de un plugin de voz.

Paso 7 — Seguridad, UX, rendimiento

Seguridad

  1. Nunca confíes en la entrada NUI. Valida tipos y longitud en el servidor.
  2. Verifica la propiedad en cada consulta con citizenid o identifier.
  3. Evita exponer identificadores a otros clientes. Usa relays del servidor.

UX

  1. Cancela el teléfono mientras estés derribado, esposado o conduciendo, si las reglas de tu servidor lo requieren.
  2. Mantén UI ágil. Usa actualizaciones optimistas y reconcilia con el acuse del servidor.

Actuación

  1. Mantén NUI inactivo. Evita bucles setInterval en React. Usa efectos y eventos.
  2. Mantén los paquetes pequeños. Carga las pantallas pesadas de forma diferida. Distribuye activos comprimidos.
  3. Usa Resmon para presupuestar un promedio inferior a 0.01–0.02 ms. Consulta la guía FiveMX enlazada arriba.

Paso 8 — Pruebas y depuración

  1. Inicia el recurso en server.cfg antes de los scripts dependientes.
ensure my_phone
  1. En el juego, presiona F8 → ejecuta nui_devTools → abre http://localhost:13172 y selecciona tu página NUI.
  2. Inspecciona la pestaña de red. Cada llamada NUI → Lua se procesa https://my_phone/<name> endpoints.
  3. Usar /myphone comando y confirmar la alternancia de enfoque.
  4. Ejecuta Resmon y verifica que la CPU se mantenga baja mientras el teléfono está abierto y cerrado.

Paso 9 — Empaquetado y actualizaciones

  1. Commit ui/ fuente y ui/dist/ build.
  2. En CI, ejecuta pnpm --filter ui build y envía solo dist en releases.
  3. Versiona tus migraciones SQL. Nunca elimines datos de usuario sin copias de seguridad.

Paso 10 — Extensiones que puedes agregar después

  1. Bancario: enlace al recurso bancario de tu servidor; expone saldo y transferencias.
  2. Tweets/Ads: feed global con límites de velocidad y moderación.
  3. Mercado: listados con depósito en garantía.
  4. Solicitudes de empleo: enlaces al MDT de policía/EMS.
  5. Fotos: integración de capturas de pantalla a través del endpoint del servidor, no URLs de datos.
  6. Ajustes: temas dinámicos, tonos de llamada, fondos.

Solución de problemas

El teléfono se abre detrás del menú de pausa
Deshabilitar durante las comprobaciones de pausa y reabrir cuando la actividad regrese.

La devolución de llamada NUI no se activa

  • Asegurar RegisterNUICallback('event', ...) los nombres coinciden con la ruta de búsqueda UI.
  • Confirmar fx_version es cerulean y ui_page apunta a ui/dist/index.html.
  • Revisa la consola F8 para errores CORS o JSON.

Los artículos no son utilizables

  • QBCore: confirmar QBCore.Functions.CreateUseableItem se ejecuta y phone existe en tu lista de artículos.
  • ESX: confirmar ESX.RegisterUsableItem('phone', ...) se registra después de que el inventario carga.

Errores de base de datos

  • Asegúrate de que oxmysql se inicie antes que este recurso.
  • Verifica los tamaños de columna y las codificaciones para nombres Unicode.

Fragmentos de referencia (copiar y pegar)

Ayudante

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

Añadir contacto desde UI

// UI
async function addContact(name: string, number: string) {
 const res = await nui<{ ok: boolean }>('contacts:add', { name, number })
 if (res.ok) {
 const next = await nui<any[]>('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)

Lo que construiste

  • Un MVP de teléfono centrado con contactos y mensajes.
  • Un puente limpio NUI que funciona en ambos frameworks.
  • Una capa de base de datos que puedes expandir de forma segura.

Lánzalo, mide el rendimiento y itera.


Lecturas adicionales


Meta

  • Resmon objetivo: ≤0.02 ms promedio en inactividad.
  • Presupuesto del paquete: ≤250 KB comprimido para MVP.
  • UI FPS: 60.