Économisez 20% dès aujourd'hui Utilisez le code BIENVENUE lors du paiement. ACCUEILLIR

Créez une application mobile personnalisée (NUI + React) pour QBCore/ESX…

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

  1. Un serveur FiveM en fonctionnement avec txAdmin et MySQL (oxmysql ou mysql-async).
  2. Node.js 18+ et pnpm ou npm sur votre PC de développement.
  3. Un framework installé : QBCore ou ESX.
  4. 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).
  5. Connaissances de base de React.

Documents

Lecture interne (FiveMX)


Architecture

  1. Ressource mon_téléphone avec fxmanifest.lua, client, serveur, et interface utilisateur paquet.
  2. Interface utilisateur: Application React intégrée avec Vite /ui/dist. NUI parle à Lua via postMessage + EnregistrerNUICallback.
  3. Données: Tables MySQL pour contacts_téléphoniques, messages_téléphoniques, appels_téléphoniques.
  4. 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

  1. Le joueur appuie sur une touche ou utilise l'élément téléphone → 2) SetNuiFocus(vrai, vrai) et EnvoyerNUIMessage({ action = 'ouvrir' }) → 3) React affiche l'interface utilisateur → 4) L'interface utilisateur demande des données via ré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

ressources/ [local]/ mon_téléphone/ client fxmanifest.lua/ serveur main.lua/ interface utilisateur main.lua/ index.html src/ main.tsx App.tsx api.ts styles.css

fxmanifest.lua

fx_version 'cerulean' jeu 'gta5' ui_page 'ui/dist/index.html' fichiers { 'ui/dist/**' } client_scripts { 'client/main.lua' } server_scripts { '@oxmysql/lib/MySQL.lua', 'server/main.lua' } lua54 'oui'

É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 } depuis 'vite' import react depuis '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], build: { outDir: 'dist', emptyOutDir: true }, base: '' })

Pont NUI

// src/api.ts export async function nui (événement : chaîne de caractères, données ? : inconnues) : Promesse { 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() si ouvert alors renvoie fin ouvert = vrai SetNuiFocus(true, true) SendNUIMessage({ action = 'open' }) fin local function closePhone() si non ouvert alors renvoie fin ouvert = faux SetNuiFocus(false, false) SendNUIMessage({ action = 'close' }) fin -- Raccourci clavier (exemple F1) RegisterCommand('myphone', function() si ouvert alors closePhone() sinon openPhone() fin fin) RegisterKeyMapping('myphone', 'Toggle Phone', 'keyboard', 'F1') -- NUI → rappels de jeu RegisterNUICallback('ui:close', function(_, cb) closePhone() cb({ ok = true }) fin) -- liste des 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. Ouvrir http://localhost:13172 dans votre navigateur Chromium pour inspecter l'interface utilisateur.


Étape 4 — Serveur : schéma de base de données + rappels

SQL (MySQL)

CRÉER UNE TABLE SI ELLE N'EXISTE PAS 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)); CRÉER UNE TABLE SI ELLE N'EXISTE PAS 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'] et exports['qb-core']:GetCoreObject() ESX = ESX ou nil si pas QBCore alors TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end) end -- Charger les contacts pour le personnage connecté lib.callback.register('my_phone:server:getContacts', function(source) local citizenid si QBCore alors local Player = QBCore.Functions.GetPlayer(source) citizenid = Player et Player.PlayerData.citizenid sinon local xPlayer = ESX.GetPlayerFromId(source) citizenid = xPlayer et xPlayer.identifier end si pas citizenid alors renvoyer {} end local rows = MySQL.query.await('SELECT id, name, number FROM phone_contacts WHERE citizenid = ?', { citizenid }) return rows ou {} 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)

QBCore

Ajoutez un élément de téléphone utilisable et basculez l'interface utilisateur lorsqu'il est utilisé.

-- server/main.lua (QBCore uniquement) si QBCore alors 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 facultative -- afficher une notification via ox_lib lib.notify({ title = 'Téléphone', description = 'Pas de téléphone pendant la conduite.', type = 'error' }) return end if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end end)

ESX

-- server/main.lua (ESX uniquement) si ESX et non QBCore alors 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

  1. Appels d'interface utilisateur contacts:list → le serveur renvoie des lignes.
  2. Ajouter le formulaire « Ajouter un contact » → appeler ajouterContact.
  3. Ajouter « Supprimer le contact » → le serveur supprime par identifiant avec contrôle de propriété citoyenne.

Messages (SMS)

  1. Tableau messages_téléphoniques propriétaire de magasin, pair, corps.
  2. L'interface utilisateur ouvre une discussion, appelle messages:list et messages : envoyer.
  3. 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é

  1. Ne faites jamais confiance aux entrées NUI. Validez les types et les longueurs sur le serveur.
  2. Vérifiez la propriété de chaque requête avec citoyenid ou identifiant.
  3. Évitez d'exposer vos identifiants à d'autres clients. Utilisez des relais de serveur.

UX

  1. Annulez le téléphone lorsque vous êtes à terre, menotté ou en voiture, si les règles de votre serveur l'exigent.
  2. 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

  1. Gardez l'interface utilisateur NUI inactive. Évitez les boucles setInterval dans React. Utilisez des effets et des événements.
  2. Limitez la taille des lots. Chargez les écrans lourds en différé. Expédiez les ressources compressées.
  3. 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

  1. Démarrer la ressource dans serveur.cfg avant les scripts dépendants.
assurer mon_téléphone
  1. Dans le jeu, appuyez sur F8 → exécuter nui_devTools → ouvert http://localhost:13172 et choisissez votre page NUI.
  2. Inspecter l'onglet réseau. Chaque appel NUI → Lua aboutit https://mon_téléphone/ points finaux.
  3. Utiliser /montéléphone commande et confirmer les bascules de focus.
  4. 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

  1. Commettre interface utilisateur/ source et interface utilisateur/dist/ construire.
  2. Dans CI, exécutez pnpm -- construction de l'interface utilisateur du filtre et expédier uniquement dist dans les communiqués.
  3. Versionnez vos migrations SQL. Ne supprimez jamais les données utilisateur sans sauvegarde.

Étape 10 — Extensions que vous pouvez ajouter ensuite

  1. Bancaire: lien vers la ressource bancaire de votre serveur ; exposer le solde et les transferts.
  2. Tweets/Publicités:flux global avec limites de débit et modération.
  3. Marché: annonces avec séquestre.
  4. Demandes d'emploi: crochets MDT de police/EMS.
  5. Photos: intégration de capture d'écran via le point de terminaison du serveur, pas les URL de données.
  6. 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_fx est azuré et page_interface utilisateur pointe vers ui/dist/index.html.
  • Vérifiez la console F8 pour les erreurs CORS ou JSON.

Articles non utilisables

  • QBCore : confirmer QBCore.Fonctions.CreateUseableItem court et téléphone existe 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

fonction GetCitizenId(source) si QBCore alors local P = QBCore.Functions.GetPlayer(source) renvoie P et P.PlayerData.citizenid sinon local xP = ESX.GetPlayerFromId(source) renvoie xP et xP.identifier fin fin

Ajouter un contact depuis l'interface utilisateur

// Fonction asynchrone d'interface utilisateur addContact(nom : chaîne, numéro : chaîne) { const res = await nui<{ ok : booléen }>('contacts:add', { nom, numéro }) 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


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

Je m'appelle Luke, je suis un joueur et j'adore écrire sur FiveM, GTA et le jeu de rôle. Je dirige une communauté de jeu de rôle et j'ai environ 10 ans d'expérience dans l'administration de serveurs.

Articles: 570