Économisez 20 % avec WELCOMEVoir les promotions
Créer une application mobile FiveM personnalisée

Créer une application mobile FiveM personnalisée

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é : QB-Core 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

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

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

  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

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


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.