NUI en FiveM con React y Vite: configuración, callbacks y desarrollo en navegador

Construye una NUI en FiveM con React y Vite: ui_page y archivos en fxmanifest, base './', SendNUIMessage, RegisterNUICallback, SetNuiFocus y desarrollo en el navegador.

HTML simple funciona para una NUI pequeña, pero una interfaz real es más fácil con React. La parte que confunde a la gente no es React, es el pegamento: el manifiesto, las rutas de salida de compilación, y cómo van y vienen los mensajes.

Este artículo configura una NUI de React y Vite que se carga en FiveM, habla con Lua en ambas direcciones, y puede compilarse en una pestaña de navegador normal.

Diseño del proyecto

Pon la interfaz en una carpeta web dentro del recurso y créala con Vite:

bash
cd my_script
npm create vite@latest web -- --template react
cd web
npm install

El resultado:

text
my_script/
  fxmanifest.lua
  client/main.lua
  web/
    package.json
    vite.config.js
    src/
    dist/        (created by the build)

vite.config: base './'

FiveM carga tu página desde una dirección nui://, no desde la raíz de un servidor web. Las rutas de activos predeterminadas de Vite comienzan con /, que luego apuntan a nada y te dejan con una página en blanco. Hazlas relativas:

js
// web/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: './',
  build: { outDir: 'dist' },
})

Luego compila:

bash
npm run build

fxmanifest.lua

Apunta ui_page al index.html compilado y envía la carpeta dist completa al jugador con files:

lua
fx_version 'cerulean'
game 'gta5'

ui_page 'web/dist/index.html'

files {
    'web/dist/index.html',
    'web/dist/**',
}

client_script 'client/main.lua'

Solo los archivos listados en files se envían a los jugadores. Si una imagen o fuente falta en el juego, el glob de archivo no la incluyó. Mantén node_modules y src fuera de la lista: los jugadores no los necesitan.

Lua a la UI: SendNUIMessage

El script del cliente envía un mensaje y da foco a la interfaz:

lua
local open = false

local function setOpen(state)
    open = state
    SetNuiFocus(state, state)
    SendNUIMessage({ action = 'setVisible', visible = state, data = { name = 'Mic' } })
end

RegisterCommand('myui', function()
    setOpen(not open)
end, false)

SetNuiFocus(hasFocus, hasCursor) da el teclado y el ratón a la página. Siempre devuélvelo, o el cursor se queda atrapado: ver NUI focus cursor atrapado.

En React, escucha el mensaje:

jsx
import { useEffect, useState } from 'react'

export default function App() {
  const [visible, setVisible] = useState(false)
  const [name, setName] = useState('')

  useEffect(() => {
    const onMessage = (event) => {
      const { action, visible, data } = event.data
      if (action === 'setVisible') {
        setVisible(visible)
        if (data) setName(data.name)
      }
    }
    window.addEventListener('message', onMessage)
    return () => window.removeEventListener('message', onMessage)
  }, [])

  if (!visible) return null
  return <div className="panel">Hello {name}</div>
}

Una página que no renderiza nada cuando está oculta es el patrón usual, ya que la página NUI en sí permanece cargada.

UI a Lua: fetch y RegisterNUICallback

Para la otra dirección, la página POSTea a una URL en tu recurso. GetParentResourceName() es una función que FiveM proporciona a la página y devuelve el nombre del recurso:

js
export async function nuiPost(eventName, data = {}) {
  const resource = window.GetParentResourceName ? window.GetParentResourceName() : 'my_script'
  const resp = await fetch(`https://${resource}/${eventName}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify(data),
  })
  return resp.json()
}
jsx
<button onClick={() => nuiPost('close')}>Close</button>

En Lua, manéjalo. El callback debe ser llamado, o la solicitud se queda pendiente:

lua
RegisterNUICallback('close', function(data, cb)
    setOpen(false)   -- gives focus back
    cb({ ok = true })
end)

data es el cuerpo JSON decodificado y cb responde al fetch. Cualquier cosa que pases a cb vuelve como la respuesta.

Desarrolla en el navegador

Recompilar y reiniciar el recurso para cada cambio de CSS es lento. Ejecuta Vite y trabaja en una pestaña de navegador normal:

bash
npm run dev

Fuera del juego, window.GetParentResourceName no existe y nadie te envía mensajes, así que simula ambos:

js
export const isBrowser = !window.invokeNative

// in main.jsx, in dev only
if (isBrowser) {
  setTimeout(() => {
    window.postMessage({ action: 'setVisible', visible: true, data: { name: 'Mic' } }, '*')
  }, 500)
}

La función nuiPost de arriba ya vuelve a un nombre de recurso predeterminado. En el navegador, la solicitud simplemente falla, así que atrápala u omite la llamada cuando isBrowser es verdadero. Usa un fondo oscuro o a cuadros en desarrollo, ya que el juego real muestra el mundo detrás de la página.

Iterando dentro del juego

Para una verificación final en el juego, compila y reinicia solo el recurso:

bash
npm run build

luego ensure my_script en la consola del servidor. Con vite build --watch, los archivos se recompilan cada vez que guardas, y solo reinicia el recurso.

Problemas comunes

  • Página en blanco. Faltante base: './', o files no incluye web/dist/**.
  • Nada sucede al hacer clic. El nombre RegisterNUICallback no coincide con la URL de fetch, o cb nunca se llama.
  • Cursor atrapado después de cerrar. Faltante SetNuiFocus(false, false) en cada ruta de cierre, incluyendo un reinicio de recurso.
  • Fuentes e imágenes faltantes en el juego. Se hace referencia a ellas por ruta absoluta, o no están en dist.
  • El mensaje nunca llega. La página aún no se había cargado, o los nombres action difieren entre Lua y el escucha.

Checklist

Síntoma Solución
UI en blanco en el juego, funciona en navegador base: './' en vite.config, luego npm run build
Archivos faltantes para jugadores files { 'web/dist/**' } en fxmanifest.lua
El botón no hace nada Coincide el nombre RegisterNUICallback y llama cb
No puedo alcanzar el juego desde la página fetch a https://${GetParentResourceName()}/name
El cursor no vuelve SetNuiFocus(false, false) al cerrar
Desarrollando sin el juego Ejecuta vite, simula postMessage y GetParentResourceName

Respuestas rápidas

¿Por qué mi NUI de React es una página en blanco en FiveM?

Normalmente la compilación usa rutas de activos absolutas. Establece base: './' en vite.config, recompila, y asegúrate de que files en fxmanifest.lua incluye la carpeta web/dist completa.

¿Cómo envía la UI datos de vuelta al juego?

Con un POST de fetch a https://${GetParentResourceName()}/eventName, manejado en Lua por RegisterNUICallback('eventName', ...), que debe llamar su callback.

¿Puedo desarrollar la NUI sin iniciar FiveM?

Sí. Ejecuta vite en el navegador, detecta que estás fuera del juego, y envía mensajes falsos con window.postMessage para simular lo que Lua enviaría.

Scripts que evitan este problema

Mic PhoneUn móvil plegable que se abre en tablet y llega al móvil real del jugador.Ver script →Clothing DesignerDiseña ropa dentro de FiveM — pincel, capas, imágenes e IA — y póntela.Ver script →Arcade MachinesSiete juegos arcade jugables en máquinas reales, con clasificaciones y apuestas.Ver script →

Sigue leyendo