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
- NUI
- Leaflet
- Lua
- Maps
En esta página
- Qué vas a construir
- El resource: fxmanifest y archivos
- Leaflet desde un CDN, o incluido
- Coordenadas del juego en un mapa de Leaflet
- Añade los tiles de GTA V
- Marcadores y blips desde coordenadas del juego
- Mostrar la posición del jugador
- Dibujar una ruta y una zona
- Hacer clic en el mapa y obtener coordenadas de GTA en Lua
- Que vaya rápido
- Preguntas frecuentes
- ¿Cómo convierto las coordenadas de GTA V para un mapa de Leaflet?
- ¿Por qué mi mapa de Leaflet sale gris en un NUI de FiveM?
- ¿Por qué mi página NUI recibe un 404 para sus propios archivos?
- ¿Dónde consigo tiles del mapa de GTA V para Leaflet?
- Aloja tus propios tiles en FiveDock
- Comprime y sube
- Apunta Leaflet a tus tiles
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:
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.
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
ui_page 'html/index.html'
files {
'html/index.html',
'html/style.css',
'html/app.js',
}my-map/
├── fxmanifest.lua
├── client.lua
└── html/
├── index.html
├── style.css
└── app.jsLeaflet 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:
<!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,
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:
// 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:
// 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:
// 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:
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)// 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:
// 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:
// 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', {});
});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
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:
# 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:
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:
cd tiles
zip -r ../my-map.zip . -x '6/*'
zip -r ../my-map-6.zip 6# my-city = your workspace slug (it is also your S3 bucket name)
rclone copy ./tiles fivedock:my-city/maps/my-map --transfers 16Apunta 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:
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.