Crea un'app per smartphone FiveM personalizzata

Crea un'app per smartphone FiveM personalizzata

Obiettivo
Crea uno smartphone in-game pronto per la produzione per FiveM usando NUI + React. Creerai una risorsa, collegherai gli eventi QBCore/ESX, persisterai i dati in MySQL e spedirai un UI fluido che rispetta i budget di performance.


Prerequisiti

  1. Un server FiveM in esecuzione con txAdmin e MySQL (oxmysql o mysql-async).
  2. Node.js 18+ e pnpm o npm sul tuo PC di sviluppo.
  3. Un framework installato: QBCore O ESX.
  4. Librerie consigliate: ox_lib (callback, notifiche), inventario_di_bue (opzionale per oggetto telefono), bersaglio_ox (opzionale per le interazioni con il mondo).
  5. Conoscenza di base di React.

Documenti

Lettura interna (FiveMX)


Architettura

  1. Risorsa my_phone con fxmanifest.lua, client, server, E ui bundle.
  2. Interfaccia utente: App React creata con Vite in /ui/dist. NUI parla con Lua tramite postMessage + RegisterNUICallback.
  3. Dati: MySQL tabelle per phone_contacts, phone_messages, phone_calls.
  4. Incollaggio del framework: QBCore O Il gestore utilizzabile dell'elemento ESX attiva il telefono e i callback del server caricano/salvano i dati.

Flusso eventi

  1. Il giocatore preme un tasto o usa l'oggetto telefono → 2) SetNuiFocus(true, true) E SendNUIMessage({ action = 'open' }) → 3) React mostra UI → 4) UI richiede dati tramite fetch('https://my_phone/xyz') (NUI) → 5) RegisterNUICallback('xyz', ...) viene eseguito su client/server → 6) Server legge/scrive DB → 7) Risposta torna alla UI → 8) Chiudi telefono e rilascia il focus.

Passo 1 — Strutturare la risorsa

Disposizione delle cartelle

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'

Passaggio 2 — Crea il React NUI

Inizializza un'app Vite React all'interno my_phone/ui e costruisci per ui/dist.

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

Configurazione Vite (assicurati che gli asset siano in 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: ''
})

NUI bridge

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

Monta React + listener messaggi

// 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 di base

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

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

Costruisci l'interfaccia:

pnpm build

Passo 3 — Client: apri/chiudi, focus NUI, callback

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

Suggerimento: abilita NUI devtools nella console di gioco con nui_devTools. Apri http://localhost:13172 nel tuo browser Chromium per ispezionare l'interfaccia.


Passaggio 4 — Server: schema DB + callback

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

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

Passo 5 — Integrazione del framework (oggetto + permessi)

QBCore

Aggiungi un oggetto telefono utilizzabile e attiva il UI quando viene utilizzato.

-- 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 usi inventario_di_bue, crea l'oggetto lì e affidati ai suoi gestori di utilizzo. Puoi comunque attivare lo stesso evento client.


Passo 6 — Funzionalità principali

Implementa piccole sezioni e rilascia in modo incrementale.

Contatti

  1. Chiamate UI contacts:list → il server restituisce righe.
  2. Aggiungi modulo “Aggiungi contatto” → chiama addContact.
  3. Aggiungi "Rimuovi contatto" → server elimina per id con controllo di proprietà del cittadino.

Messaggi (SMS)

  1. Tavolo phone_messages proprietario del negozio, pari, corpo.
  2. L'interfaccia apre una chat, chiama messages:list E messages:send.
  3. Il server inserisce il messaggio, opzionalmente emette un evento client al peer se online.

Schema server

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)

Il client riceve

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

Chiamate (MVP opzionale)

  • Salva solo i log delle chiamate. L'audio reale usa il tuo plugin vocale (pma-voice, mumble, SaltyChat) ed è al di fuori di questo MVP.
  • Aggiungi il tastierino UI → alla composizione, registra una chiamata in uscita; alla risposta, registra una chiamata in entrata. Puoi integrarlo in seguito con il API di un plugin vocale.

Passaggio 7 — Sicurezza, UX, prestazioni

Sicurezza

  1. Non fidarti mai dell'input NUI. Convalida tipi e lunghezza sul server.
  2. Controlla la proprietà su ogni query con citizenid O identifier.
  3. Evita di esporre gli identificatori ad altri client. Usa i relay del server.

UX

  1. Annulla il telefono mentre sei a terra, ammanettato o alla guida, se le regole del tuo server lo richiedono.
  2. Mantieni l'interfaccia reattiva. Usa aggiornamenti ottimistici e riconcilia dopo l'ack del server.

Prestazione

  1. Mantieni NUI inattivo. Evita i cicli setInterval in React. Usa effetti ed eventi.
  2. Mantieni i bundle piccoli. Carica in modo differito le schermate pesanti. Spedisci risorse compresse.
  3. Usa Resmon per un budget medio inferiore a 0.01–0.02 ms. Vedi la guida FiveMX collegata sopra.

Passaggio 8 — Test e debug

  1. Avvia risorsa in server.cfg prima degli script dipendenti.
ensure my_phone
  1. In gioco, premi F8 → esegui nui_devTools → apri http://localhost:13172 e scegli la tua pagina NUI.
  2. Ispeziona la scheda di rete. Ogni chiamata NUI → Lua colpisce https://my_phone/<name> endpoint.
  3. Utilizzo /myphone comando e conferma l'attivazione del focus.
  4. Esegui Resmon e verifica che la CPU rimanga bassa mentre il telefono è aperto e chiuso.

Passaggio 9 — Packaging e aggiornamenti

  1. Commit ui/ fonte e ui/dist/ build.
  2. In CI, esegui pnpm --filter ui build e spedisci solo dist nelle release.
  3. Versiona le tue migrazioni SQL. Non eliminare mai i dati utente senza backup.

Passo 10 — Estensioni che puoi aggiungere in seguito

  1. Bancario: collega alla risorsa bancaria del tuo server; espone saldo e trasferimenti.
  2. Tweet/Annunci: feed globale con limiti di frequenza e moderazione.
  3. Mercato: annunci con escrow.
  4. Candidature lavori: hook MDT polizia/EMS.
  5. Foto: integrazione screenshot tramite endpoint server, non URL di dati.
  6. Impostazioni: temi dinamici, suonerie, sfondi.

Risoluzione dei problemi

Il telefono si apre dietro il menu di pausa
Disabilita durante i controlli di pausa e riapri quando l'attività riprende.

NUI callback non attivato

  • Garantire RegisterNUICallback('event', ...) i nomi corrispondono al percorso di recupero UI.
  • Confermare fx_version È cerulean E ui_page punti a ui/dist/index.html.
  • Controlla la console F8 per errori CORS o JSON.

Oggetti non utilizzabili

  • QBCore: conferma QBCore.Functions.CreateUseableItem esecuzioni e phone esiste nella tua lista di oggetti.
  • ESX: conferma ESX.RegisterUsableItem('phone', ...) si registra dopo il caricamento dell'inventario.

Errori del database

  • Assicurati che oxmysql sia stato avviato prima di questa risorsa.
  • Controlla le dimensioni delle colonne e le codifiche per i nomi Unicode.

Frammenti di riferimento (copia-incolla)

Aiuto

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

Aggiungi contatto da 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)

Cosa hai costruito

  • Un MVP telefonico mirato con contatti e messaggi.
  • Un bridge NUI pulito che funziona su entrambi i framework.
  • Un livello DB che puoi espandere in sicurezza.

Spedisci, misura le prestazioni e itera.


Ulteriori letture


Meta

  • Resmon target: ≤0.02 ms in media inattivo.
  • Budget bundle: ≤250 KB gzip per MVP.
  • UI FPS: 60.