Mapa interativo de GTA V no NUI do FiveM com Leaflet
Monte um mapa de GTA V com zoom num NUI do FiveM usando Leaflet: CRS para coordenadas do jogo, blips, posição do jogador, rotas, zonas e waypoint ao clicar.
Por Termi
- NUI
- Leaflet
- Lua
- Maps
Nesta página
- O que você vai montar
- O resource: fxmanifest e arquivos
- Leaflet por CDN, ou embutido
- Coordenadas do jogo num mapa Leaflet
- Adicione os tiles do GTA V
- Marcadores e blips a partir de coordenadas do jogo
- Mostre a posição do jogador
- Desenhe uma rota e uma zona
- Clique no mapa e receba coordenadas do GTA no Lua
- Mantenha rápido
- Perguntas frequentes
- Como converto as coordenadas do GTA V para um mapa Leaflet?
- Por que meu mapa Leaflet fica cinza num NUI do FiveM?
- Por que minha página NUI recebe 404 nos próprios arquivos?
- Onde consigo tiles do mapa do GTA V para o Leaflet?
- Hospede seus próprios tiles no FiveDock
- Compacte e envie
- Aponte o Leaflet para os seus tiles
A maioria das interfaces de mapa do FiveM começa igual: alguém tira um print do mapa do menu de pausa, joga num <img> e passa uma noite brigando com desvio de pixels. O Leaflet já sabe mostrar uma imagem enorme como tiles com zoom, desenhar marcadores, linhas e círculos, e dizer onde o usuário clicou. Só falta um sistema de coordenadas que combine com o GTA V.
Este guia monta um mapa NUI do zero para um resource de FiveM: a configuração do Leaflet, o CRS que converte as coordenadas do jogo, blips, a posição do jogador ao vivo a partir do Lua, uma rota, uma zona e um clique no mapa que define um waypoint. Os tiles do GTA V ficam na CDN do FiveDock e são grátis, então você tem um mapa funcionando sem hospedar nada.
O que você vai montar
Um resource com uma página NUI que abre um mapa grande de GTA V quando você digita /map. O mapa:
Tudo usa o Leaflet 1.9, que roda sem problema no navegador do NUI.
O resource: fxmanifest e arquivos
Um resource NUI é um resource comum com uma ui_page. Todo arquivo que a página carrega do resource também precisa estar listado em files, senão o navegador do NUI recebe um 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 por CDN, ou embutido
O jeito mais rápido é carregar o Leaflet de uma CDN. O navegador do NUI carrega scripts remotos e faz cache deles como qualquer outro navegador. Fixe a versão e mantenha os hashes de integridade, para que um arquivo alterado na CDN nunca seja executado:
<!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;
}Se preferir não depender de uma CDN de terceiros, embuta o Leaflet: baixe leaflet.js e leaflet.css para html/vendor/, aponte as duas tags para lá e adicione 'html/vendor/*' ao files. Se o seu NUI é um app em Vite ou React, rode npm install leaflet e importe; o resto do guia continua igual.
Os pins de marcador padrão do Leaflet são imagens que ficam ao lado do CSS dele, e num NUI elas também precisam estar em files. Este guia usa circle markers, que não precisam de nenhuma imagem.
Coordenadas do jogo num mapa Leaflet
O Leaflet pensa em latitude e longitude numa Terra redonda. O GTA V é plano e medido em metros, com x crescendo para leste e y para norte. L.CRS.Simple é o sistema de coordenadas plano do Leaflet. Mantemos a projeção dele, L.Projection.LonLat, que usa a longitude como x e a latitude como y, e trocamos a transformação por uma que alinha as coordenadas do jogo aos tiles do 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 };
}O que os números significam: no zoom 0 o mapa inteiro é o primeiro tile, de 256 × 256 pixels, e uma posição do jogo (x, y) cai no pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8). Cada nível de zoom dobra isso (scale é 2 elevado ao zoom), então no zoom 5 o mapa tem 8.192 pixels de largura. O fator de y é negativo porque o y do jogo aponta para o norte e o y do pixel aponta para baixo. Os tiles do FiveDock são cortados exatamente para essa calibração.
Uma regra para lembrar
O Leaflet usa [lat, lng], então uma posição do jogo entra como [y, x]. Chame toLatLng(x, y) em todo lugar e você nunca vai trocar os dois sem querer.
Adicione os tiles do GTA V
Crie o mapa com o CRS e adicione a camada de tiles. O FiveDock hospeda um conjunto de tiles do GTA V que qualquer pessoa pode usar, nos níveis 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);Duas opções fazem o trabalho pesado. bounds importa com esse CRS: infinite: true significa que o Leaflet não sabe onde o mundo acaba, e sem limites ele pediria tiles bem fora do mapa, que só podem voltar como 404. maxNativeZoom deixa os jogadores darem zoom um nível além do último nível de tiles: o Leaflet amplia os tiles do zoom 5 em vez de pedir tiles que não existem.
Marcadores e blips a partir de coordenadas do jogo
Tudo cujas coordenadas do jogo você conhece pode ir para o mapa. Com preferCanvas, os circle markers são desenhados em um único canvas, então até centenas deles continuam 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);
}As coordenadas são aproximadas; fique em algum ponto dentro do jogo e imprima GetEntityCoords(PlayerPedId()) para pegar as exatas. Se preferir ícones, L.marker com um L.icon apontando para uma imagem na sua pasta html/ funciona do mesmo jeito, desde que a imagem esteja em files.
Mostre a posição do jogador
A página NUI não consegue ler o estado do jogo, então o Lua do client precisa enviá-lo. O SendNUIMessage entrega uma tabela para a página como um evento message. Abra o mapa com um comando e, enquanto ele estiver aberto, envie a posição quatro vezes 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;
});Repare na chamada a map.invalidateSize(). O Leaflet mede o container quando o mapa é criado, e o nosso começa escondido. Sem essa chamada você fica com um mapa cinza e um único tile no canto.
Desenhe uma rota e uma zona
Linhas e círculos usam as mesmas coordenadas convertidas. Uma rota é uma polyline por posições do jogo; uma zona é um círculo com raio em unidades do jogo:
// 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);Quando o CRS não é a Terra, o L.circle mede o raio nas unidades do próprio CRS, então radius: 80 são 80 metros no jogo. Uma rota de verdade, feita dos seus waypoints ou de um script de GPS, é só uma lista de pontos mais longa.
Clique no mapa e receba coordenadas do GTA no Lua
Um clique devolve um LatLng, e com o nosso CRS o lng dele é o x do jogo e o lat é o y. Envie para um callback do 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)O GetParentResourceName() está disponível em qualquer página NUI e devolve o nome do seu resource. O + 0.0 garante que os valores cheguem à native como floats. O mesmo padrão serve para qualquer coisa que você queira escolher num mapa: um ponto de spawn, um local de entrega, uma cena de crime para o seu MDT.
Mantenha rápido
Perguntas frequentes
Como converto as coordenadas do GTA V para um mapa Leaflet?
Use L.CRS.Simple com a projeção L.Projection.LonLat e a sua própria L.Transformation calibrada para os tiles, e passe cada posição do jogo como [y, x], porque o Leaflet recebe a latitude primeiro. Com os tiles do FiveDock, uma posição do jogo (x, y) cai no pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8) no zoom 0, e cada nível de zoom dobra isso. Um pequeno helper toLatLng(x, y) evita que você troque os dois.
Por que meu mapa Leaflet fica cinza num NUI do FiveM?
O Leaflet mede o container quando o mapa é criado, e um mapa em NUI normalmente começa escondido, então ele se dimensiona para nada e mostra uma área cinza com um tile no canto. Chame map.invalidateSize() logo depois de mostrar o mapa. Se o console se encher de 404 de tiles, defina bounds na camada de tiles para o Leaflet parar de pedir tiles fora do mapa.
Por que minha página NUI recebe 404 nos próprios arquivos?
Todo arquivo que a página NUI carrega do resource, sejam scripts, estilos ou imagens, precisa estar listado em files no fxmanifest.lua, ao lado de ui_page. Um arquivo que não está na lista nunca é enviado ao client, então o navegador do NUI recebe um 404. As imagens de marcador padrão do Leaflet também contam, por isso este guia usa circle markers.
Onde consigo tiles do mapa do GTA V para o Leaflet?
O FiveDock hospeda um conjunto de tiles do GTA V nos níveis de zoom 0 a 5 que qualquer pessoa pode usar num NUI do FiveM, sem hospedar nada por conta própria. Para um estilo seu, como um render de satélite ou um mapa com ruas customizadas, corte tiles {z}/{x}/{y} com uma ferramenta como gdal2tiles.py -p raster --xyz e coloque numa CDN.
Hospede seus próprios tiles no FiveDock
Os tiles compartilhados cobrem o jogo base. Se você tem um estilo próprio (um render de satélite, um tema escuro, um mapa com suas ruas e MLOs customizados), hospede do mesmo jeito. O Leaflet quer uma pasta por nível de zoom, uma pasta por coluna dentro dela e uma imagem por tile: {z}/{x}/{y}.png (ou .jpg, como o mapa base). Qualquer ferramenta que gere tiles XYZ serve, por exemplo gdal2tiles.py -p raster --xyz. Mantenha o enquadramento do mapa base (a mesma área do mundo, uma imagem de 8.192 × 8.192 pixels no zoom 5, começando no canto superior esquerdo dos tiles) e a calibração acima continua valendo; se a sua imagem cobre outra área ou fica em outro zoom, os números da transformação mudam junto.
Compacte e envie
Compacte o conteúdo da pasta de tiles, não a pasta em si: as pastas dentro de um arquivo compactado são mantidas, então tiles/ apareceria em todo link:
# tiles/0/0/0.png, tiles/1/0/0.png, ... tiles/5/31/31.png
cd tiles && zip -r ../my-map.zip .O FiveDock descompacta arquivos .zip e .rar no upload. No painel, abra Arquivos, crie a pasta maps/my-map e abra-a, depois clique em Enviar um arquivo e solte o zip. Manter nomes dos arquivos vem ativado por padrão para arquivos compactados; deixe ativado. O arquivo traz direto as pastas de zoom, não tiles/, então cada tile vira um arquivo com uma chave estável, como maps/my-map/3/2/1.png. O mesmo upload pela API, com um token que tenha o escopo 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"A resposta lista cada arquivo guardado em uploaded e tudo que foi ignorado em skipped, com o motivo.
Conjuntos grandes de tiles
Um arquivo compactado pode ter até 5.000 arquivos. O mapa base do zoom 0 ao 5 tem 1.365 tiles e cabe em um. Cortando um nível a mais, só o zoom 6 já soma 4.096, e o conjunto não cabe mais: coloque esse nível num arquivo compactado separado (rodando da mesma pasta, a pasta do zoom fica nas chaves). Cada nível seguinte é de novo quatro vezes maior; para conjuntos desse tamanho, copie a pasta inteira pelo endpoint S3 com 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 16Aponte o Leaflet para os seus tiles
Cada tile agora tem um link que nunca muda, https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png, onde <your-workspace> é o slug da sua organização no painel. Coloque-o na camada 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);Achou um tile com defeito? Envie um corrigido com a mesma chave, por exemplo maps/my-map/4/3/2.png. Ele substitui o arquivo antigo atrás do mesmo link, e os jogadores o veem em cerca de cinco minutos, quando a cópia em cache expira.
Esse é o mapa inteiro: as seis partes do app.js acima, em ordem, num só arquivo. Dê ensure no resource, entre no servidor e digite /map. Os mesmos links com chave servem para tudo que a sua UI mostra, de imagens de itens a mugshots.