El Libro·Capítulo 8/12·11 min

NUI: interfaces sobre el juego

NUI es la capa web que FiveM dibuja sobre el juego: tus menús, HUD y teléfonos son HTML, CSS y JavaScript hablando con Lua.

¿Te has fijado en que los menús bonitos de FiveM —el HUD, el teléfono, la tienda de ropa— no parecen del juego, sino una página web? Es que lo son. FiveM incrusta un navegador (CEF, el mismo motor Chromium de Chrome) y lo dibuja por encima del juego. A esa capa se le llama NUI (New UI). Tú la programas con lo que ya conoces de la web: HTML para la estructura, CSS para el aspecto y JavaScript para la lógica.

La clave de NUI es que vive en el CLIENTE: es el navegador del jugador. Por eso necesita un puente para hablar con tu script de Lua, y a través de él, con el servidor. En este capítulo montamos ese puente de principio a fin con un ejemplo coherente: un panel que se abre, muestra datos y manda una acción de vuelta.

1. Declarar la interfaz en el fxmanifest

Lo primero es decirle a FiveM cuál es tu página y qué archivos web debe servir. Dos cosas: ui_page apunta al HTML principal, y files{} lista TODO lo que el navegador necesita cargar (html, css, js, imágenes…). Si un archivo no está en files{}, el navegador no lo encontrará.

lua
fx_version 'cerulean'
game 'gta5'

author 'Crxative-M'
description 'Panel NUI de ejemplo'
version '1.0.0'

client_scripts {
  'client.lua'
}

server_scripts {
  'server.lua'
}

-- La página que se dibuja sobre el juego
ui_page 'html/index.html'

-- Todo lo que el navegador debe poder cargar
files {
  'html/index.html',
  'html/style.css',
  'html/app.js'
}

fxmanifest.lua

Las rutas de files{} son relativas a la raíz del recurso. Si tu HTML está en html/index.html, el ui_page y los files{} deben usar exactamente esa ruta. Una barra mal puesta = pantalla en blanco.

2. El HTML: oculto por defecto

La NUI se carga al iniciar el recurso y se queda SIEMPRE encima del juego. Por eso tu interfaz debe nacer oculta (display:none) y solo mostrarse cuando Lua lo pida. Si no, tendrías un panel tapando la pantalla todo el rato.

text
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8" />
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <!-- Oculto hasta que Lua mande 'open' -->
  <div id="panel" style="display:none">
    <h1 id="titulo"></h1>
    <p>Dinero: <span id="dinero"></span>$</p>
    <button id="btnCobrar">Cobrar sueldo</button>
    <button id="btnCerrar">Cerrar</button>
  </div>
  <script src="app.js"></script>
</body>
</html>

html/index.html

3. De Lua a JavaScript: SendNUIMessage

Para hablarle a la interfaz, Lua usa SendNUIMessage con una tabla. Esa tabla llega a JavaScript como un objeto. La convención universal es incluir un campo action que diga QUÉ hacer, y el resto de datos junto a él. Y muy importante: si quieres que el jugador pueda mover el ratón y hacer clic, hay que darle el foco con SetNuiFocus(true, true).

lua
-- client.lua
RegisterCommand('panel', function()
  -- Mostramos el cursor y damos foco a la NUI
  SetNuiFocus(true, true)
  -- Enviamos la orden de abrir, con datos para pintar
  SendNUIMessage({
    action = 'open',
    titulo = 'Panel del trabajador',
    dinero = 1500
  })
end, false)

client.lua — abrir el panel

SetNuiFocus(true, true): el primer true da el foco (el juego deja de capturar el teclado/ratón para la NUI), el segundo muestra el cursor. Para un HUD que solo informa y no recibe clics, no des foco: deja que el jugador siga jugando.

4. JavaScript escucha el mensaje

En el lado web, los mensajes de Lua llegan como un evento message en window. Dentro de e.data tienes la tabla que enviaste. Lees e.data.action y actúas en consecuencia.

javascript
// app.js
const panel = document.getElementById('panel');

window.addEventListener('message', (e) => {
  const data = e.data;
  if (data.action === 'open') {
    document.getElementById('titulo').textContent = data.titulo;
    document.getElementById('dinero').textContent = data.dinero;
    panel.style.display = 'block';
  }
  if (data.action === 'close') {
    panel.style.display = 'none';
  }
});

app.js — recibir de Lua y pintar

5. De JavaScript a Lua: fetch al callback

Cuando el jugador pulsa un botón, el JavaScript le devuelve la pelota a Lua con un fetch. La URL siempre tiene la forma https://NOMBRE_DEL_RECURSO/nombreCallback, y ese nombre del recurso lo da la función GetParentResourceName(). Nunca lo escribas a mano: si renombras el recurso, dejaría de funcionar.

javascript
// app.js — seguimos
document.getElementById('btnCobrar').addEventListener('click', () => {
  fetch(`https://${GetParentResourceName()}/cobrarSueldo`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ cantidad: 250 })
  })
    .then((resp) => resp.json())
    .then((res) => console.log('Lua respondió:', res));
});

document.getElementById('btnCerrar').addEventListener('click', () => {
  // Pedimos a Lua que nos quite el foco y cierre
  fetch(`https://${GetParentResourceName()}/cerrar`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({})
  });
});

app.js — enviar a Lua

6. Lua recibe con RegisterNUICallback

Cada fetch necesita su pareja en Lua: un RegisterNUICallback con el MISMO nombre. La función recibe data (lo que mandaste en el body) y cb, una función de respuesta que SIEMPRE debes llamar para cerrar el ciclo (aunque sea con cb('ok')). Aquí es donde pedimos al servidor lo importante y donde cerramos el foco.

lua
-- client.lua
RegisterNUICallback('cobrarSueldo', function(data, cb)
  -- NO damos dinero aquí: el cliente no es de confianza.
  -- Solo avisamos al servidor para que valide y decida.
  TriggerServerEvent('miscript:cobrarSueldo', data.cantidad)
  cb('ok') -- siempre respondemos al fetch
end)

RegisterNUICallback('cerrar', function(data, cb)
  SetNuiFocus(false, false)            -- soltamos el foco y el cursor
  SendNUIMessage({ action = 'close' }) -- ocultamos el panel
  cb('ok')
end)

client.lua — recibir de la NUI

7. El servidor es quien manda

Regla de oro: la NUI y el cliente NO son de confianza. Cualquier jugador puede abrir las DevTools y disparar tu fetch con la cantidad que quiera. Por eso el dinero, los objetos y los permisos se deciden SIEMPRE en el servidor, que es tu máquina y la única autoridad. El cliente solo pide; el servidor comprueba y concede.

lua
-- server.lua
RegisterNetEvent('miscript:cobrarSueldo', function(cantidad)
  local src = source -- quién lo pidió (no se puede falsear)

  -- Validamos en el servidor: nunca confíes en el número del cliente
  if type(cantidad) ~= 'number' or cantidad <= 0 or cantidad > 250 then
    return -- intento sospechoso: lo ignoramos
  end

  -- Aquí darías el dinero de verdad (ESX, QBCore, tu DB...)
  -- Player(src).addMoney(cantidad)
  print(('Jugador %s cobró %s$'):format(src, cantidad))
end)

server.lua — la validación real

El flujo completo, de un vistazo

  • 1) El jugador escribe /panel → Lua da el foco con SetNuiFocus(true,true) y manda action:'open' con SendNUIMessage.
  • 2) JavaScript escucha el message, pinta los datos y muestra el panel.
  • 3) El jugador pulsa 'Cobrar' → JavaScript hace fetch a https://recurso/cobrarSueldo.
  • 4) RegisterNUICallback recibe el fetch, avisa al SERVIDOR con TriggerServerEvent y llama a cb('ok').
  • 5) El servidor VALIDA la petición y concede (o ignora) el dinero.
  • 6) Al cerrar, Lua hace SetNuiFocus(false,false) y manda action:'close' para ocultar el panel.

Errores típicos (y cómo evitarlos)

  • Olvidar SetNuiFocus(false, false) al cerrar: el cursor se queda pegado y el jugador no puede moverse ni mirar. Es EL bug clásico de NUI.
  • Rutas mal en ui_page o files{}: si la ruta no coincide exactamente con la carpeta real, sale pantalla en blanco o el CSS/JS no carga.
  • Escribir el nombre del recurso a mano en el fetch en vez de usar GetParentResourceName(): se rompe en cuanto renombras el recurso.
  • Validar en el cliente en lugar del servidor: dar dinero/objetos en RegisterNUICallback es una puerta abierta a los tramposos. Decide siempre en server.lua.
  • Olvidar llamar a cb(...) en el callback: el fetch queda colgado esperando respuesta.

Depurar NUI: CEF tiene sus propias DevTools. Con el recurso corriendo, abre en tu navegador http://localhost:13172 (o usa la consola de FiveM) para ver la consola, los errores de JavaScript y la pestaña Network de tus fetch. Truco del foco: si te quedas con el cursor atascado por un bug, escribe en la consola F8 del cliente: SetNuiFocus(false, false) para liberarte mientras pruebas.

¿Una duda sobre esto? El chat de la IA lo sabe todo y te responde con código.

Pregunta a la IA
NUI en FiveM: interfaces con HTML, CSS y JavaScript