But
Créez un smartphone de jeu prêt pour la production pour FiveM en utilisant NUI et React. Vous développerez une ressource, connecterez des événements QBCore/ESX, conserverez les données dans MySQL et créerez une interface utilisateur fluide et performante.
Prérequis
- Un serveur FiveM en fonctionnement avec txAdmin et MySQL (oxmysql ou mysql-async).
- Node.js 18+ et pnpm ou npm sur votre PC de développement.
- Un framework installé : QB-Core ou ESX.
- Bibliothèques recommandées : ox_lib (rappels, notifications), ox_inventaire (facultatif pour l'élément téléphonique), cible_ox (facultatif pour les interactions mondiales).
- Connaissances de base de React.
Documents
- Présentation de Cfx.re NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/
- Rappels NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/nui-callbacks/
- EnvoyerNUIMessage – https://docs.fivem.net/docs/scripting-reference/runtimes/lua/functions/SendNUIMessage/
- Déboguer les outils de développement NUI – https://docs.fivem.net/docs/scripting-manual/nui-development/full-screen-nui/
- Fonctions du serveur QBCore – https://docs.qbcore.org/qbcore-documentation/qb-core/server-function-reference
- ESX
Enregistrer l'élément utilisable– https://docs.esx-framework.org/en/esx_core/es_extended/server/functions - rappels ox_lib – https://overextended.dev/ox_lib
Lecture interne (FiveMX)
- Resmon et performance – https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Pôle de performance – https://fivemx.com/performance/
- Aperçu du marché des scripts téléphoniques – https://fivemx.com/phone-scripts/
Architecture
- Ressource
mon_téléphoneavecfxmanifest.lua,client,serveur, etinterface utilisateurpaquet. - Interface utilisateur: Application React intégrée avec Vite
/ui/dist. NUI parle à Lua viapostMessage+EnregistrerNUICallback. - Données: Tables MySQL pour
contacts_téléphoniques,messages_téléphoniques,appels_téléphoniques. - Colle à cadre: QBCore ou Le gestionnaire d'éléments utilisables ESX bascule le téléphone et les rappels du serveur chargent/enregistrent les données.
Déroulement des événements
- Le joueur appuie sur une touche ou utilise l'élément téléphone → 2)
SetNuiFocus(vrai, vrai)etEnvoyerNUIMessage({ action = 'ouvrir' })→ 3) React affiche l'interface utilisateur → 4) L'interface utilisateur demande des données viarécupérer('https://mon_téléphone/xyz')(NUI) → 5)EnregistrerNUICallback('xyz', ...)s'exécute sur le client/serveur → 6) Le serveur lit/écrit la base de données → 7) La réponse revient à l'interface utilisateur → 8) Fermez le téléphone et relâchez le focus.
Étape 1 — Échafauder la ressource
Disposition des dossiers
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'
Étape 2 — Créer l'interface utilisateur React NUI
Initialiser une application Vite React à l'intérieur mon_téléphone/ui et construire pour interface utilisateur/dist.
cd my_phone/ui pnpm créer vite@latest . --template react-ts pnpm i
Configuration Vite (s'assurer que les actifs atterrissent dans 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: ''
})
Pont 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()
}
Monter React + écouteur de messages
// 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 utilisateur de 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>
)
}
index.html
<!doctype html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>mon_télé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>
Créer l'interface utilisateur :
Construction de pnpm
Étape 3 — Client : ouverture/fermeture, focus NUI, rappels
-- 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
-- Raccourci clavier (exemple F1)
RegisterCommand('myphone', function()
if open then closePhone() else openPhone() end
end)
RegisterKeyMapping('myphone', 'Basculer le téléphone', 'keyboard', 'F1')
-- NUI → callbacks du jeu
RegisterNUICallback('ui:close', function(_, cb)
closePhone()
cb({ ok = true })
end)
-- list contacts demande au serveur
RegisterNUICallback('contacts:list', function(_, cb)
lib.callback('my_phone:server:getContacts', false, function(rows)
cb(rows)
end)
end)
Astuce : activez les outils de développement NUI dans la console de jeu avec
nui_devTools. Ouvrirhttp://localhost:13172dans votre navigateur Chromium pour inspecter l'interface utilisateur.
Étape 4 — Serveur : schéma de base de données + rappels
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) );
Serveur avec 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
-- Charger les contacts du personnage connecté
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)
-- Enregistrer un 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)
Étape 5 — Intégration du framework (élément + autorisations)
QB-Core
Ajoutez un élément de téléphone utilisable et basculez l'interface utilisateur lorsqu'il est utilisé.
-- server/main.lua (QBCore uniquement)
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 -- règle optionnelle
-- afficher une notification via ox_lib
lib.notify({ title = 'Téléphone', description = 'Pas de téléphone en conduisant.', type = 'error' })
return
end
if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end
end)
ESX
-- server/main.lua (ESX uniquement)
if ESX and not QBCore then
ESX.RegisterUsableItem('phone', function(playerId)
TriggerClientEvent('my_phone:client:toggle', playerId)
end)
end
Si vous utilisez ox_inventaire, créez l'élément à cet endroit et utilisez ses gestionnaires utilisables. Vous pouvez toujours déclencher le même événement client.
Étape 6 — Fonctionnalités principales
Implémentez de petites tranches et expédiez progressivement.
Contacts
- Appels d'interface utilisateur
contacts:list→ le serveur renvoie des lignes. - Ajouter le formulaire « Ajouter un contact » → appeler
ajouterContact. - Ajouter « Supprimer le contact » → le serveur supprime par
identifiantavec contrôle de propriété citoyenne.
Messages (SMS)
- Tableau
messages_téléphoniquespropriétaire de magasin, pair, corps. - L'interface utilisateur ouvre une discussion, appelle
messages:listetmessages : envoyer. - Le serveur insère un message et émet éventuellement un événement client vers l'homologue s'il est en ligne.
Croquis du serveur
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)
Le client reçoit
RegisterNetEvent('my_phone:client:messages:push', function(peer, body)
SendNUIMessage({ action = 'message:new', peer = peer, body = body })
end)
Appels (MVP optionnel)
- Stocker uniquement les journaux d'appels. L'audio réel utilise votre plugin vocal (pma-voice, mumble, SaltyChat) et est hors de ce MVP.
- Ajoutez un clavier d'interface utilisateur → lors de la numérotation, enregistrez un appel sortant ; lors de la réponse, enregistrez un appel entrant. Vous pouvez intégrer ultérieurement l'API d'un plugin vocal.
Étape 7 — Sécurité, UX, performances
Sécurité
- Ne faites jamais confiance aux entrées NUI. Validez les types et les longueurs sur le serveur.
- Vérifiez la propriété de chaque requête avec
citoyenidouidentifiant. - Évitez d'exposer vos identifiants à d'autres clients. Utilisez des relais de serveur.
UX
- Annulez le téléphone lorsque vous êtes à terre, menotté ou en voiture, si les règles de votre serveur l'exigent.
- Maintenez une interface utilisateur réactive. Utilisez des mises à jour optimistes et effectuez une réconciliation lors de l'accusé de réception du serveur.
Performance
- Gardez l'interface utilisateur NUI inactive. Évitez les boucles setInterval dans React. Utilisez des effets et des événements.
- Limitez la taille des lots. Chargez les écrans lourds en différé. Expédiez les ressources compressées.
- Utilisez Resmon pour budgétiser moins de 0,01 à 0,02 ms en moyenne. Consultez le guide FiveMX ci-dessus.
Étape 8 — Test et débogage
- Démarrer la ressource dans
serveur.cfgavant les scripts dépendants.
assurer mon_téléphone
- Dans le jeu, appuyez sur F8 → exécuter
nui_devTools→ ouverthttp://localhost:13172et choisissez votre page NUI. - Inspecter l'onglet réseau. Chaque appel NUI → Lua aboutit
https://mon_téléphone/points finaux. - Utiliser
/montéléphonecommande et confirmer les bascules de focus. - Exécutez Resmon et vérifiez que le processeur reste faible lorsque le téléphone est ouvert et fermé.
Étape 9 — Emballage et mises à jour
- Commettre
interface utilisateur/source etinterface utilisateur/dist/construire. - Dans CI, exécutez
pnpm -- construction de l'interface utilisateur du filtreet expédier uniquementdistdans les communiqués. - Versionnez vos migrations SQL. Ne supprimez jamais les données utilisateur sans sauvegarde.
Étape 10 — Extensions que vous pouvez ajouter ensuite
- Bancaire: lien vers la ressource bancaire de votre serveur ; exposer le solde et les transferts.
- Tweets/Publicités:flux global avec limites de débit et modération.
- Marché: annonces avec séquestre.
- Demandes d'emploi: crochets MDT de police/EMS.
- Photos: intégration de capture d'écran via le point de terminaison du serveur, pas les URL de données.
- Paramètres: thèmes dynamiques, sonneries, arrière-plans.
Dépannage
Le téléphone s'ouvre derrière le menu pause
Désactiver les vérifications pendant la pause et rouvrir lorsque l'activité revient.
Le rappel NUI ne se déclenche pas
- Assurer
RegisterNUICallback('événement', ...)les noms correspondent au chemin de récupération de l'interface utilisateur. - Confirmer
version_fxestazuréetpage_interface utilisateurpointe versui/dist/index.html. - Vérifiez la console F8 pour les erreurs CORS ou JSON.
Articles non utilisables
- QBCore : confirmer
QBCore.Fonctions.CreateUseableItemcourt ettéléphoneexiste dans votre liste d'articles. - ESX : confirmer
ESX.RegisterUsableItem('téléphone', ...)enregistre après les chargements d'inventaire.
Erreurs de base de données
- Assurez-vous qu'oxmysql a démarré avant cette ressource.
- Vérifiez les tailles de colonnes et les codages pour les noms Unicode.
Extraits de référence (copier-coller)
Auxiliaire
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
Ajouter un contact depuis l'interface utilisateur
// 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')
// mettre à jour l'état
}
}
-- client
RegisterNUICallback('contacts:add', function(data, cb)
lib.callback('my_phone:server:addContact', false, function(resp)
cb(resp)
end, data)
end)
Ce que tu as construit
- Un MVP téléphonique ciblé avec contacts et messages.
- Un pont NUI propre qui fonctionne sur les deux frameworks.
- Une couche DB que vous pouvez étendre en toute sécurité.
Expédiez-le, mesurez les performances et itérez.
Lectures complémentaires
- FiveMX Resmon & optimisation : https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Comparaison de voix (choisissez votre pile) : https://fivemx.com/fivem-voice-mumble-saltychat-pma-voice-guide/
- Téléphones existants pour l'inspiration :
- lb-phone v2 : https://fivemx.com/lb-phone-v2/
- Smartphone Quasar : https://fivemx.com/phone-scripts/
- GCPhone : https://fivemx.com/free/gcphone/
- Z-Phone : https://fivemx.com/free/z-phone/
Méta
- Réponse cible : ≤ 0,02 ms en moyenne en mode inactif.
- Budget du bundle : ≤ 250 Ko compressés pour MVP.
- FPS de l'interface utilisateur : 60.
Ressources FiveMX associées
Utilisez ces sections pour comparer les catégories FiveM connexes et configurer les ressources avant de modifier un serveur en production.