Zbuduj niestandardową aplikację telefoniczną FiveM

Zbuduj niestandardową aplikację telefoniczną FiveM

Cel
Stwórz gotowy do produkcji smartfon w grze dla FiveM używając NUI + React. Zbudujesz zasób, połączysz zdarzenia QBCore/ESX, zapiszesz dane w MySQL i dostarczysz płynny UI, który szanuje budżet wydajnościowy.


Wymagania wstępne

  1. Działający serwer FiveM z txAdmin i MySQL (oxmysql lub mysql-async).
  2. Node.js 18+ i pnpm lub npm na twoim komputerze deweloperskim.
  3. Zainstalowany jeden framework: QBCore Lub ESX.
  4. Zalecane biblioteki: ox_lib (callbacki, powiadomienia), ox_inventory (opcjonalnie dla przedmiotu telefonu), ox_target (opcjonalnie dla interakcji ze światem).
  5. Podstawowa znajomość Reacta.

Dokumentacja

Czytanie wewnętrzne (FiveMX)


Architektura

  1. Zasób my_phone z fxmanifest.lua, client, server, oraz ui pakiet.
  2. UI: Aplikacja React zbudowana z Vite do /ui/dist. NUI komunikuje się z Lua przez postMessage + RegisterNUICallback.
  3. Dane: Tabele MySQL dla phone_contacts, phone_messages, phone_calls.
  4. Klej frameworka: QBCore Lub ESX obsługa przedmiotu włączająca telefon, a callbacki serwera ładują/zapisują dane.

Przepływ zdarzeń

  1. Gracz naciska klawisz lub używa przedmiotu telefon → 2) SetNuiFocus(true, true) I SendNUIMessage({ action = 'open' }) → 3) React pokazuje UI → 4) UI żąda danych przez fetch('https://my_phone/xyz') (NUI) → 5) RegisterNUICallback('xyz', ...) uruchamia się na kliencie/serwerze → 6) Serwer odczytuje/zapisuje DB → 7) Odpowiedź wraca do UI → 8) Zamknij telefon i zwolnij fokus.

Krok 1 — Stwórz szkielet zasobu

Układ folderów

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'

Krok 2 — Utwórz React NUI

Zainicjuj aplikację Vite React wewnątrz my_phone/ui i zbuduj do ui/dist.

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

Konfiguracja Vite (upewnij się, że zasoby trafiają do 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 most

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

Zamontuj React + nasłuchiwacz wiadomości

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

Podstawowy UI

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

Zbuduj UI:

pnpm build

Krok 3 — Klient: otwieranie/zamykanie, fokus NUI, callbacki

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

Wskazówka: włącz narzędzia deweloperskie NUI w konsoli gry za pomocą nui_devTools. Otwórz http://localhost:13172 w przeglądarce Chromium, aby sprawdzić UI.


Krok 4 — Serwer: schemat bazy danych + callbacki

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

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

Krok 5 — Integracja frameworka (przedmiot + uprawnienia)

QBCore

Dodaj używalny przedmiot telefonu i przełącz UI po użyciu.

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

Jeśli używasz ox_inventory, utwórz tam przedmiot i polegaj na jego handlerach używalności. Nadal możesz wywołać to samo zdarzenie klienckie.


Krok 6 — Podstawowe funkcje

Implementuj małe fragmenty i dostarczaj stopniowo.

Kontakty

  1. UI wywołuje contacts:list → serwer zwraca wiersze.
  2. Dodaj formularz “Dodaj kontakt” → wywołaj addContact.
  3. Dodaj “Usuń kontakt” → serwer usuwa po id z kontrolą własności obywatela.

Wiadomości (SMS)

  1. Tabela phone_messages przechowuje właściciela, rozmówcę, treść.
  2. UI otwiera czat, wywołuje messages:list I messages:send.
  3. Serwer wstawia wiadomość, opcjonalnie emituje zdarzenie klienta do rozmówcy, jeśli jest online.

Szkic serwera

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)

Odbieranie przez klienta

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

Połączenia (opcjonalne MVP)

  • Przechowuj tylko logi połączeń. Rzeczywiste audio używa twojego pluginu głosowego (pma-voice, mumble, SaltyChat) i jest poza tym MVP.
  • Dodaj klawiaturę UI → przy wybieraniu, zaloguj połączenie wychodzące; przy odbieraniu, zaloguj przychodzące. Możesz później zintegrować z API pluginu głosowego.

Krok 7 — Bezpieczeństwo, UX, wydajność

Bezpieczeństwo

  1. Nigdy nie ufaj danym wejściowym z NUI. Waliduj typy i długość na serwerze.
  2. Sprawdzaj własność przy każdym zapytaniu za pomocą citizenid Lub identifier.
  3. Unikaj ujawniania identyfikatorów innym klientom. Używaj przekaźników serwerowych.

UX

  1. Anuluj telefon, gdy jesteś powalony, skuty lub prowadzisz, jeśli wymagają tego zasady serwera.
  2. Utrzymuj UI szybki. Stosuj optymistyczne aktualizacje i uzgadniaj po potwierdzeniu serwera.

Wydajność

  1. Utrzymuj NUI bezczynny. Unikaj pętli setInterval w React. Używaj efektów i zdarzeń.
  2. Utrzymuj małe pakiety. Ładuj leniwie ciężkie ekrany. Dostarczaj skompresowane zasoby.
  3. Użyj Resmon, aby utrzymać budżet poniżej średnio 0,01–0,02 ms. Zobacz przewodnik FiveMX powyżej.

Krok 8 — Testowanie i debugowanie

  1. Uruchom zasób w server.cfg przed zależnymi skryptami.
ensure my_phone
  1. W grze naciśnij F8 → uruchom nui_devTools → otwórz http://localhost:13172 i wybierz swoją stronę NUI.
  2. Sprawdź zakładkę sieci. Każde NUI → wywołanie Lua trafia https://my_phone/<name> punkty końcowe.
  3. Używać /myphone polecenie i potwierdź przełączanie fokusu.
  4. Uruchom Resmon i sprawdź, czy CPU pozostaje niski, gdy telefon jest otwarty i zamknięty.

Krok 9 — Pakowanie i aktualizacje

  1. Zatwierdź ui/ źródło i ui/dist/ buduj.
  2. W CI uruchom pnpm --filter ui build i wysyłaj tylko dist w wydaniach.
  3. Wersjonuj migracje SQL. Nigdy nie usuwaj danych użytkowników bez kopii zapasowych.

Krok 10 — Rozszerzenia, które możesz dodać następnie

  1. Bankowość: link do zasobu bankowego twojego serwera; udostępniaj saldo i przelewy.
  2. Tweety/Reklamy: globalny kanał z ograniczeniami częstotliwości i moderacją.
  3. Marketplace: ogłoszenia z depozytem.
  4. Aplikacje o pracę: policja/EMS haki MDT.
  5. Zdjęcia: integracja zrzutów ekranu przez endpoint serwera, a nie adresy URL danych.
  6. Ustawienia: dynamiczne motywy, dzwonki, tła.

Rozwiązywanie problemów

Telefon otwiera się za menu pauzy
Wyłącz podczas sprawdzania pauzy i otwórz ponownie, gdy aktywność powróci.

NUI callback nie jest wywoływany

  • Zapewnić RegisterNUICallback('event', ...) nazwy pasują do ścieżki pobierania UI.
  • Potwierdź fx_version jest cerulean I ui_page wskazuje na ui/dist/index.html.
  • Sprawdź konsolę F8 pod kątem błędów CORS lub JSON.

Przedmioty nieużywalne

  • QBCore: potwierdź QBCore.Functions.CreateUseableItem działa i phone istnieje na liście twoich przedmiotów.
  • ESX: potwierdź ESX.RegisterUsableItem('phone', ...) rejestruje się po załadowaniu ekwipunku.

Błędy bazy danych

  • Upewnij się, że oxmysql został uruchomiony przed tym zasobem.
  • Sprawdź rozmiary kolumn i kodowania dla nazw Unicode.

Fragmenty referencyjne (kopiuj-wklej)

Pomocnik

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

Dodaj kontakt z 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)

To, co zbudowałeś

  • Skoncentrowany MVP telefonu z kontaktami i wiadomościami.
  • Czysty mostek NUI działający na obu frameworkach.
  • Warstwa bazy danych, którą można bezpiecznie rozszerzać.

Wysyłaj, mierz wydajność i powtarzaj.


Dalsza lektura


Meta

  • Docelowy resmon: średnio ≤0,02 ms w stanie bezczynności.
  • Budżet pakietu: ≤250 KB skompresowany gzip dla MVP.
  • UI FPS: 60.