Saltar al contenido
Blog

Mapa interactivo de GTA V para NUI de FiveM con Leaflet

Crea un mapa de GTA V con zoom en un NUI de FiveM con Leaflet: CRS para coordenadas del juego, blips, posición del jugador, rutas, zonas y waypoint al clic.

Por Termi

12 min de lectura
  • NUI
  • Leaflet
  • Lua
  • Maps
En esta página

La mayoría de las interfaces de mapa de FiveM empiezan igual: alguien hace una captura del mapa del menú de pausa, la mete en un <img> y se pasa una tarde peleando con desfases de píxeles. Leaflet ya sabe mostrar una imagen enorme como tiles con zoom, dibujar marcadores, líneas y círculos, y decirte dónde ha hecho clic el usuario. Solo le falta un sistema de coordenadas que encaje con GTA V.

Esta guía construye desde cero un mapa NUI para un resource de FiveM: la configuración de Leaflet, el CRS que convierte las coordenadas del juego, blips, la posición del jugador en vivo desde Lua, una ruta, una zona y un clic en el mapa que pone un waypoint. Los tiles de GTA V están alojados en el CDN de FiveDock y son gratis, así que tienes un mapa funcionando sin alojar nada tú mismo.

Qué vas a construir

Un resource con una página NUI que abre un mapa grande de GTA V cuando escribes /map. El mapa:

  • sigue al jugador, con la posición enviada desde el Lua del cliente cuatro veces por segundo,
  • muestra blips de los lugares que defines en coordenadas del juego,
  • dibuja una ruta entre dos puntos y una zona circular,
  • devuelve a Lua las coordenadas del juego de un clic, donde ponemos un waypoint.

Todo usa Leaflet 1.9, que funciona sin problemas en el navegador de NUI.

El resource: fxmanifest y archivos

Un resource con NUI es un resource normal con una ui_page. Todo archivo que la página carga desde el resource tiene que estar también en files, o el navegador de NUI recibe un 404.

fxmanifest.lua
fx_version 'cerulean'
game 'gta5'

client_script 'client.lua'

ui_page 'html/index.html'

files {
    'html/index.html',
    'html/style.css',
    'html/app.js',
}
files
my-map/
├── fxmanifest.lua
├── client.lua
└── html/
    ├── index.html
    ├── style.css
    └── app.js

Leaflet desde un CDN, o incluido

Lo más rápido es cargar Leaflet desde un CDN. El navegador de NUI carga scripts remotos y los cachea como cualquier otro navegador. Fija la versión y conserva los hashes de integridad, para que un archivo cambiado en el CDN nunca se ejecute:

html/index.html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link
      rel="stylesheet"
      href="https://unpkg.com/[email protected]/dist/leaflet.css"
      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
      crossorigin=""
    />
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div id="map"></div>
    <script
      src="https://unpkg.com/[email protected]/dist/leaflet.js"
      integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
      crossorigin=""
    ></script>
    <script src="app.js"></script>
  </body>
</html>
html/style.css
html,
body {
  margin: 0;
  height: 100%;
  background: transparent;
}

#map {
  position: absolute;
  inset: 5vh 5vw;
  display: none;
  border-radius: 12px;
  /* the sea colour of the tiles, behind the edges and tiles still loading */
  background: #0fa8d2;
}

#map.open {
  display: block;
}

Si prefieres no depender de un CDN de terceros, inclúyelo: descarga leaflet.js y leaflet.css en html/vendor/, haz que las dos etiquetas apunten ahí y añade 'html/vendor/*' a files. Si tu NUI es una app de Vite o React, haz npm install leaflet e impórtalo; el resto de la guía queda igual.

Los pines de marcador por defecto de Leaflet son imágenes que viven junto a su CSS, y en un NUI también tienen que estar en files. Esta guía usa circle markers, que no necesitan ninguna imagen.

Coordenadas del juego en un mapa de Leaflet

Leaflet piensa en latitud y longitud sobre una Tierra redonda. GTA V es plano y se mide en metros, con x creciendo hacia el este e y hacia el norte. L.CRS.Simple es el sistema de coordenadas plano de Leaflet. Nos quedamos con su proyección, L.Projection.LonLat, que usa la longitud como x y la latitud como y, y le ponemos una transformación que alinea las coordenadas del juego con los tiles del mapa:

html/app.js
// app.js, part 1: the coordinate system
const GTA_CRS = L.extend({}, L.CRS.Simple, {
  projection: L.Projection.LonLat,
  scale: (zoom) => Math.pow(2, zoom),
  zoom: (scale) => Math.log(scale) / Math.LN2,
  transformation: new L.Transformation(0.02072, 117.3, -0.0205, 172.8),
  infinite: true,
});

// A GTA world position (x, y) is L.latLng(y, x) on this map.
function toLatLng(x, y) {
  return L.latLng(y, x);
}

// ...and back: lng is the game x, lat is the game y.
function toGame(latlng) {
  return { x: latlng.lng, y: latlng.lat };
}

Qué significan los números: en el zoom 0 todo el mapa es el primer tile, de 256 × 256 píxeles, y una posición del juego (x, y) cae en el píxel (0.02072 · x + 117.3, −0.0205 · y + 172.8). Cada nivel de zoom lo duplica (scale es 2 elevado al zoom), así que en el zoom 5 el mapa mide 8192 píxeles de ancho. El factor de y es negativo porque la y del juego apunta al norte mientras que la y de los píxeles apunta hacia abajo. Los tiles de FiveDock están cortados exactamente para esta calibración.

Una regla que recordar

Leaflet toma [lat, lng], así que una posición del juego entra como [y, x]. Llama a toLatLng(x, y) en todas partes y nunca las cambiarás de orden por accidente.

Añade los tiles de GTA V

Crea el mapa con el CRS y añade la capa de tiles. FiveDock aloja un conjunto de tiles de GTA V para que lo use cualquiera, en los niveles de zoom 0 a 5:

html/app.js
// app.js, part 2: the map and the tiles
const TILE_SIZE = 256;
const MAP_SIZE = 8192;
const NATIVE_ZOOM = 5;

const map = L.map('map', {
  crs: GTA_CRS,
  minZoom: 2,
  maxZoom: 6,
  zoomControl: false,
  preferCanvas: true,
});

// The tiles hold one MAP_SIZE × MAP_SIZE image at zoom 5, from their top-left corner.
const worldBounds = L.latLngBounds(
  map.unproject([0, MAP_SIZE], NATIVE_ZOOM),
  map.unproject([MAP_SIZE, 0], NATIVE_ZOOM),
);

L.tileLayer('https://cdn.fivedock.dev/o/fivedock/maps/styleatlas/{z}/{x}/{y}.jpg', {
  tileSize: TILE_SIZE,
  minZoom: 0,
  maxZoom: 6,
  maxNativeZoom: NATIVE_ZOOM,
  bounds: worldBounds,
  attribution: 'Map © Rockstar Games',
}).addTo(map);

map.setMaxBounds(worldBounds.pad(0.1));
map.setView(toLatLng(0, 0), 3);

Dos opciones hacen el trabajo pesado. bounds importa con este CRS: infinite: true significa que Leaflet no sabe dónde acaba el mundo, y sin límites pediría tiles muy fuera del mapa que solo pueden volver como 404. maxNativeZoom deja que los jugadores hagan zoom un nivel más allá del último nivel de tiles: Leaflet amplía los tiles del zoom 5 en lugar de pedir tiles que no existen.

Marcadores y blips desde coordenadas del juego

Todo lo que tenga coordenadas del juego puede ir en el mapa. Con preferCanvas, los circle markers se dibujan en un solo canvas, así que incluso cientos de ellos siguen fluidos:

html/app.js
// app.js, part 3: blips
const blips = [
  { label: 'Mission Row PD', x: 441.0, y: -982.0, color: '#3b82f6' },
  { label: 'Pillbox Hill Medical', x: 298.6, y: -584.5, color: '#ef4444' },
  { label: 'LSIA', x: -1037.0, y: -2737.0, color: '#f59e0b' },
];

for (const blip of blips) {
  L.circleMarker(toLatLng(blip.x, blip.y), {
    radius: 6,
    color: '#ffffff',
    weight: 2,
    fillColor: blip.color,
    fillOpacity: 1,
  })
    .bindTooltip(blip.label)
    .addTo(map);
}

Las coordenadas son aproximadas; ponte en un sitio dentro del juego e imprime GetEntityCoords(PlayerPedId()) para obtener las exactas. Si prefieres iconos, L.marker con un L.icon que apunte a una imagen de tu carpeta html/ funciona igual, siempre que la imagen esté en files.

Mostrar la posición del jugador

La página NUI no puede leer el estado del juego, así que el Lua del cliente tiene que enviarlo. SendNUIMessage entrega una tabla a la página como un evento message. Abre el mapa con un comando y, mientras esté abierto, envía la posición cuatro veces por segundo:

client.lua
local isOpen = false

RegisterCommand('map', function()
    isOpen = true
    SetNuiFocus(true, true)
    SendNUIMessage({ action = 'open' })
end, false)

CreateThread(function()
    while true do
        if isOpen then
            local coords = GetEntityCoords(PlayerPedId())
            SendNUIMessage({ action = 'player', x = coords.x, y = coords.y })
            Wait(250)
        else
            Wait(1000)
        end
    end
end)
html/app.js
// app.js, part 4: messages from Lua
const mapElement = document.getElementById('map');
let player = null;
let followPlayer = true;

window.addEventListener('message', (event) => {
  const data = event.data;

  if (data.action === 'open') {
    mapElement.classList.add('open');
    map.invalidateSize();
    followPlayer = true;
  }

  if (data.action === 'close') {
    mapElement.classList.remove('open');
  }

  if (data.action === 'player') {
    const position = toLatLng(data.x, data.y);
    if (player) {
      player.setLatLng(position);
    } else {
      player = L.circleMarker(position, {
        radius: 7,
        color: '#ffffff',
        weight: 2,
        fillColor: '#22c55e',
        fillOpacity: 1,
      }).addTo(map);
    }
    if (followPlayer) map.panTo(position, { animate: false });
  }
});

// stop following once the player drags the map around
map.on('dragstart', () => {
  followPlayer = false;
});

Fíjate en la llamada a map.invalidateSize(). Leaflet mide su contenedor cuando se crea el mapa, y el nuestro empieza oculto. Sin esa llamada obtienes un mapa gris con un solo tile en una esquina.

Dibujar una ruta y una zona

Las líneas y los círculos usan las mismas coordenadas convertidas. Una ruta es una polyline por posiciones del juego; una zona es un círculo con un radio en unidades del juego:

html/app.js
// app.js, part 5: a route and a zone
// [x, y] in game coordinates: Mission Row PD to Pillbox Hill Medical
const route = [
  [441.0, -982.0],
  [402.0, -860.0],
  [298.6, -584.5],
];

L.polyline(
  route.map(([x, y]) => toLatLng(x, y)),
  { color: '#22c55e', weight: 4, opacity: 0.9 },
).addTo(map);

// radius in game units, which are metres
L.circle(toLatLng(441.0, -982.0), {
  radius: 80,
  color: '#3b82f6',
  fillOpacity: 0.15,
}).addTo(map);

Cuando el CRS no es la Tierra, L.circle mide su radio en las unidades del propio CRS, así que radius: 80 son 80 metros en el juego. Una ruta real, de tus propios waypoints o de un script de GPS, es solo una lista de puntos más larga.

Hacer clic en el mapa y obtener coordenadas de GTA en Lua

Un clic te da un LatLng, y con nuestro CRS su lng es la x del juego y su lat la y. Envíalo a un callback de NUI:

html/app.js
// app.js, part 6: clicks and closing, back to Lua
function post(name, body) {
  return fetch(`https://${GetParentResourceName()}/${name}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify(body),
  });
}

map.on('click', (event) => {
  post('mapClick', toGame(event.latlng));
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') post('close', {});
});
client.lua
RegisterNUICallback('mapClick', function(data, cb)
    SetNewWaypoint(data.x + 0.0, data.y + 0.0)
    cb('ok')
end)

RegisterNUICallback('close', function(_, cb)
    isOpen = false
    SetNuiFocus(false, false)
    SendNUIMessage({ action = 'close' })
    cb('ok')
end)

GetParentResourceName() está disponible en cualquier página NUI y devuelve el nombre de tu resource. El + 0.0 asegura que los valores lleguen a la native como floats. El mismo patrón sirve para cualquier cosa que quieras elegir en un mapa: un punto de spawn, un lugar de entrega, una escena del crimen para tu MDT.

Que vaya rápido

  • Leaflet solo pide los tiles a la vista en el zoom actual más un pequeño margen, nunca toda la pirámide, así que abrir el mapa cuesta un puñado de imágenes pequeñas, no megas.
  • NUI es Chromium con una caché HTTP normal. Los enlaces con clave de FiveDock (/o/…) pueden cachearse 5 minutos y luego se revalidan con un ETag, así que un tile sin cambios cuesta un diminuto 304 en lugar de una descarga.
  • Los tiles no están en files, así que no añaden nada a lo que los jugadores descargan al entrar a tu servidor.
  • Cuatro actualizaciones de posición por segundo se ven fluidas en un mapa; mandar una en cada frame mantiene a Lua y al NUI ocupados para nada.
  • preferCanvas: true dibuja circle markers, líneas y zonas en un solo canvas en vez de cientos de elementos SVG.
  • Mantén un solo mapa y alterna su visibilidad en lugar de reconstruirlo. Crear un mapa nuevo en cada apertura tira los tiles y marcadores que ya tienes.

Preguntas frecuentes

¿Cómo convierto las coordenadas de GTA V para un mapa de Leaflet?

Usa L.CRS.Simple con su proyección L.Projection.LonLat y tu propia L.Transformation calibrada a los tiles, y pasa cada posición del juego como [y, x], porque Leaflet toma la latitud primero. Con los tiles de FiveDock, una posición del juego (x, y) cae en el píxel (0.02072 · x + 117.3, −0.0205 · y + 172.8) en el zoom 0, y cada nivel de zoom lo duplica. Un pequeño helper toLatLng(x, y) evita que las cambies de orden.

¿Por qué mi mapa de Leaflet sale gris en un NUI de FiveM?

Leaflet mide su contenedor cuando se crea el mapa, y un mapa de NUI suele empezar oculto, así que se dimensiona a nada y muestra una zona gris con un tile en una esquina. Llama a map.invalidateSize() justo después de mostrar el mapa. Si la consola se llena de 404 de tiles, define bounds en la capa de tiles para que Leaflet deje de pedir tiles fuera del mapa.

¿Por qué mi página NUI recibe un 404 para sus propios archivos?

Todo archivo que la página NUI carga desde el resource, ya sean scripts, hojas de estilo o imágenes, tiene que estar en files dentro de fxmanifest.lua, junto a ui_page. Un archivo que no está listado nunca se envía al cliente, así que el navegador de NUI recibe un 404. Las imágenes de marcador por defecto de Leaflet también cuentan, por eso esta guía usa circle markers.

¿Dónde consigo tiles del mapa de GTA V para Leaflet?

FiveDock aloja un conjunto de tiles de GTA V en los niveles de zoom 0 a 5 que cualquiera puede usar en un NUI de FiveM, sin alojar nada tú mismo. Para un estilo propio, como un render de satélite o un mapa con carreteras personalizadas, corta tiles {z}/{x}/{y} con una herramienta como gdal2tiles.py -p raster --xyz y ponlos en un CDN.

Aloja tus propios tiles en FiveDock

Los tiles compartidos cubren el juego base. Si tienes tu propio estilo (un render de satélite, un tema oscuro, un mapa con tus carreteras y MLO personalizados), alójalo de la misma forma. Leaflet quiere una carpeta por nivel de zoom, una carpeta por columna dentro y una imagen por tile: {z}/{x}/{y}.png (o .jpg, como el mapa base). Vale cualquier herramienta que escriba tiles XYZ, por ejemplo gdal2tiles.py -p raster --xyz. Mantén el encuadre del mapa base (la misma zona del mundo, una imagen de 8192 × 8192 píxeles en el zoom 5, que empieza en la esquina superior izquierda de los tiles) y la calibración de arriba sigue valiendo; si tu imagen cubre otra zona o está en otro zoom, los números de la transformación cambian con ella.

Comprime y sube

Comprime el contenido de la carpeta de tiles, no la carpeta en sí: las carpetas dentro de un archivo comprimido se conservan, así que tiles/ acabaría en todos los enlaces:

bash
# tiles/0/0/0.png, tiles/1/0/0.png, ... tiles/5/31/31.png
cd tiles && zip -r ../my-map.zip .

FiveDock descomprime archivos .zip y .rar al subirlos. En el panel, abre Archivos, crea la carpeta maps/my-map y ábrela, luego pulsa Subir un archivo y suelta el zip. Mantener nombres de archivo viene activado por defecto para los archivos comprimidos; déjalo activado. El archivo contiene directamente las carpetas de zoom, no tiles/, así que cada tile pasa a ser un archivo con una clave estable como maps/my-map/3/2/1.png. La misma subida por la API, con un token que tenga el scope files:write:

bash
curl -X POST https://api.fivedock.dev/api/v3/file/archive \
  -H "Authorization: Bearer fd_live_…" \
  -F "[email protected]" \
  -F "path=maps/my-map"

La respuesta lista cada archivo guardado en uploaded y todo lo que se omitió en skipped, con un motivo.

Conjuntos grandes de tiles

Un archivo comprimido puede contener hasta 5000 archivos. El mapa base del zoom 0 al 5 tiene 1365 tiles y cabe en uno. Si cortas un nivel más, solo el zoom 6 añade 4096, y el conjunto ya no cabe: pon ese nivel en un archivo comprimido aparte (ejecutado desde la misma carpeta, la carpeta del zoom se queda en las claves). Cada nivel siguiente es otra vez cuatro veces mayor; para conjuntos así de grandes, copia la carpeta entera por el endpoint S3 con rclone:

bash
cd tiles
zip -r ../my-map.zip . -x '6/*'
zip -r ../my-map-6.zip 6
rclone
# my-city = your workspace slug (it is also your S3 bucket name)
rclone copy ./tiles fivedock:my-city/maps/my-map --transfers 16

Apunta Leaflet a tus tiles

Cada tile tiene ahora un enlace que nunca cambia, https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png, donde <your-workspace> es el slug de tu organización en el panel. Ponlo en la capa de tiles:

html/app.js
L.tileLayer('https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png', {
  tileSize: TILE_SIZE,
  minZoom: 0,
  maxZoom: 6,
  maxNativeZoom: NATIVE_ZOOM,
  bounds: worldBounds,
}).addTo(map);

¿Has encontrado un tile malo? Sube uno corregido con la misma clave, por ejemplo maps/my-map/4/3/2.png. Reemplaza el archivo antiguo detrás del mismo enlace, y los jugadores lo ven en unos cinco minutos, cuando caduca su copia en caché.

Y ese es todo el mapa: las seis partes de app.js de arriba, en orden, en un solo archivo. Haz ensure del resource, entra y escribe /map. Los mismos enlaces con clave sirven para todo lo demás que muestre tu UI, desde imágenes de items hasta mugshots.

Todos los artículos