Interaktywna mapa GTA V w NUI na FiveM z Leaflet
Mapa GTA V z zoomem w NUI na FiveM z Leaflet: CRS dla koordynatów z gry, blipy, pozycja gracza na żywo, trasy, strefy i waypoint po kliknięciu.
Autor: Termi
- NUI
- Leaflet
- Lua
- Maps
Na tej stronie
- Co zbudujemy
- Resource: fxmanifest i pliki
- Leaflet z CDN albo w paczce
- Koordynaty z gry na mapie Leaflet
- Dodaj kafelki GTA V
- Markery i blipy z koordynatów z gry
- Pozycja gracza na mapie
- Trasa i strefa
- Kliknięcie w mapę, koordynaty GTA w Lua
- Żeby działało szybko
- Częste pytania
- Jak przeliczyć koordynaty GTA V na mapę Leaflet?
- Czemu mapa Leaflet w NUI na FiveM jest szara?
- Czemu strona NUI dostaje 404 na własne pliki?
- Skąd wziąć kafelki mapy GTA V do Leafleta?
- Własne kafelki mapy na FiveDock
- Spakuj i wgraj
- Podepnij swoje kafelki w Leaflecie
Większość map w NUI powstaje tak samo: ktoś robi screena mapy z menu pauzy, wrzuca go do <img> i spędza wieczór na walce z przesunięciami pikseli. Da się prościej. Leaflet potrafi już pokazać ogromny obraz jako kafelki z zoomem, rysować markery, linie i okręgi oraz powiedzieć, gdzie kliknął użytkownik. Brakuje mu tylko układu współrzędnych, który pasuje do GTA V.
W tym poradniku budujemy mapę w NUI od zera: konfiguracja Leafleta, CRS przeliczający koordynaty z gry, blipy, pozycja gracza wysyłana z Lua, trasa, strefa i kliknięcie w mapę, które ustawia waypoint. Kafelki GTA V leżą na CDN FiveDock i można z nich korzystać za darmo, więc działającą mapę masz bez hostowania czegokolwiek.
Co zbudujemy
Resource ze stroną NUI, która po wpisaniu /map otwiera dużą mapę GTA V. Mapa:
Wszystko na Leaflecie 1.9, który bez problemu działa w przeglądarce NUI.
Resource: fxmanifest i pliki
Resource z NUI to zwykły resource z ui_page. Każdy plik, który strona ładuje z resource’a, musi być też wpisany w files, inaczej przeglądarka NUI dostanie 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 z CDN albo w paczce
Najszybciej jest załadować Leafleta z CDN. Przeglądarka NUI ładuje zdalne skrypty i cache’uje je jak każda inna przeglądarka. Przypnij wersję i zostaw hashe integrity, żeby plik podmieniony na CDN nigdy się nie uruchomił:
<!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;
}Jeśli nie chcesz zależeć od cudzego CDN, dołącz go do resource’a: pobierz leaflet.js i leaflet.css do html/vendor/, podepnij je w obu tagach i dopisz 'html/vendor/*' do files. Jeśli twoje NUI jest zbudowane w Vite albo React, zrób npm install leaflet i zaimportuj go; reszta poradnika się nie zmienia.
Domyślne pinezki Leafleta to obrazki leżące obok jego CSS, a w NUI one też muszą być w files. W tym poradniku używamy circle markerów, które nie potrzebują żadnych obrazków.
Koordynaty z gry na mapie Leaflet
Leaflet myśli w szerokości i długości geograficznej na okrągłej Ziemi. GTA V jest płaskie i liczone w metrach: x rośnie na wschód, y na północ. L.CRS.Simple to płaski układ współrzędnych Leafleta. Zostawiamy jego projekcję, L.Projection.LonLat, która traktuje długość jako x, a szerokość jako y, i podmieniamy transformację tak, żeby koordynaty z gry trafiały w kafelki mapy:
// 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 };
}Co oznaczają te liczby: przy zoomie 0 cała mapa to pierwszy kafelek, 256 × 256 pikseli, a pozycja z gry (x, y) trafia w piksel (0.02072 · x + 117.3, −0.0205 · y + 172.8). Każdy poziom zoomu to podwaja (scale to 2 do potęgi zoomu), więc przy zoomie 5 mapa ma 8192 pikseli szerokości. Współczynnik dla y jest ujemny, bo y w grze rośnie na północ, a y piksela w dół. Kafelki FiveDock są pocięte dokładnie pod tę kalibrację.
Jedna zasada do zapamiętania
Leaflet przyjmuje [lat, lng], więc pozycja z gry wchodzi jako [y, x]. Wołaj wszędzie toLatLng(x, y), a nigdy ich przypadkiem nie zamienisz.
Dodaj kafelki GTA V
Utwórz mapę z tym CRS i dodaj warstwę kafelków. FiveDock hostuje kafelki GTA V dla wszystkich, na poziomach zoomu od 0 do 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);Najważniejsze są tu dwie opcje. bounds ma znaczenie przy tym CRS: infinite: true oznacza, że Leaflet nie wie, gdzie kończy się świat, i bez granic prosiłby o kafelki daleko poza mapą, które mogą wrócić tylko jako 404. maxNativeZoom pozwala przybliżyć o jeden poziom dalej niż ostatnie kafelki: Leaflet powiększa kafelki z zoomu 5, zamiast prosić o nieistniejące.
Markery i blipy z koordynatów z gry
Na mapę trafi wszystko, czego koordynaty znasz. Dzięki preferCanvas circle markery rysują się na jednym canvasie, więc nawet setki z nich chodzą płynnie:
// 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);
}Koordynaty są przybliżone; stań w danym miejscu w grze i wypisz GetEntityCoords(PlayerPedId()), żeby mieć dokładne. Jeśli wolisz ikony, L.marker z L.icon wskazującym obrazek w folderze html/ działa tak samo, byle obrazek był w files.
Pozycja gracza na mapie
Strona NUI nie ma dostępu do stanu gry, więc musi go wysłać client Lua. SendNUIMessage dostarcza tabelę do strony jako zdarzenie message. Otwieramy mapę komendą i dopóki jest otwarta, wysyłamy pozycję cztery razy na sekundę:
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;
});Zwróć uwagę na map.invalidateSize(). Leaflet mierzy kontener przy tworzeniu mapy, a nasz na starcie jest ukryty. Bez tego wywołania dostaniesz szarą mapę z jednym kafelkiem w rogu.
Trasa i strefa
Linie i okręgi przyjmują te same przeliczone koordynaty. Trasa to polilinia przez pozycje z gry, a strefa to okrąg z promieniem w jednostkach gry:
// 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);Gdy CRS nie jest Ziemią, L.circle liczy promień w jednostkach samego CRS, więc radius: 80 to 80 metrów w grze. Prawdziwa trasa, z twoich waypointów albo ze skryptu GPS, to po prostu dłuższa lista punktów.
Kliknięcie w mapę, koordynaty GTA w Lua
W drugą stronę CRS pokazuje, ile jest wart. Kliknięcie daje LatLng, a w naszym CRS jego lng to x z gry, a lat to y. Wysyłamy je do callbacku 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() jest dostępne na każdej stronie NUI i zwraca nazwę twojego resource’a. + 0.0 pilnuje, żeby do natywki trafiły floaty. Ten sam wzorzec działa dla wszystkiego, co chcesz wskazać na mapie: punktu spawnu, miejsca dostawy, miejsca zdarzenia w MDT.
Żeby działało szybko
Częste pytania
Jak przeliczyć koordynaty GTA V na mapę Leaflet?
Użyj L.CRS.Simple z jego projekcją L.Projection.LonLat i własną L.Transformation skalibrowaną pod kafelki, a każdą pozycję z gry podawaj jako [y, x], bo Leaflet bierze najpierw szerokość geograficzną. Na kafelkach FiveDock pozycja (x, y) z gry trafia przy zoomie 0 na piksel (0.02072 · x + 117.3, −0.0205 · y + 172.8), a każdy kolejny poziom zoomu to podwaja. Mały helper toLatLng(x, y) pilnuje, żeby ich nie zamienić.
Czemu mapa Leaflet w NUI na FiveM jest szara?
Leaflet mierzy swój kontener przy tworzeniu mapy, a mapa w NUI zwykle startuje ukryta, więc ustawia sobie zerowy rozmiar i pokazuje szare pole z jednym kafelkiem w rogu. Wywołaj map.invalidateSize() zaraz po pokazaniu mapy. Jeśli konsola zapełnia się błędami 404 dla kafelków, ustaw bounds na warstwie kafelków, żeby Leaflet przestał prosić o kafelki spoza mapy.
Czemu strona NUI dostaje 404 na własne pliki?
Każdy plik, który strona NUI ładuje z resource’a, czy to skrypt, styl, czy obrazek, musi być wpisany w files w fxmanifest.lua, obok ui_page. Plik spoza tej listy nigdy nie trafia do klienta, więc przeglądarka NUI dostaje na niego 404. Dotyczy to też domyślnych obrazków markerów Leafleta, dlatego ten poradnik używa markerów w kształcie kółek.
Skąd wziąć kafelki mapy GTA V do Leafleta?
FiveDock hostuje zestaw kafelków GTA V na poziomach zoomu od 0 do 5, z którego każdy może korzystać w NUI na FiveM bez hostowania czegokolwiek u siebie. Jeśli chcesz własny styl, np. render satelitarny albo mapę z własnymi drogami, potnij kafelki {z}/{x}/{y} narzędziem w rodzaju gdal2tiles.py -p raster --xyz i wrzuć je na CDN.
Własne kafelki mapy na FiveDock
Wspólne kafelki pokrywają podstawową grę. Jeśli masz własny styl (render satelitarny, ciemny motyw, mapę z własnymi drogami i MLO), hostujesz go tak samo. Leaflet chce folderu na każdy poziom zoomu, w nim folderu na każdą kolumnę i jednego obrazka na kafelek: {z}/{x}/{y}.png (albo .jpg, jak podstawowa mapa). Nada się każde narzędzie, które tnie kafelki XYZ, na przykład gdal2tiles.py -p raster --xyz. Zachowaj kadr podstawowej mapy (ten sam obszar świata, jeden obraz 8192 × 8192 pikseli przy zoomie 5, zaczynający się w lewym górnym rogu kafelków), a kalibracja z góry dalej pasuje; jeśli twój obraz obejmuje inny obszar albo leży na innym zoomie, zmieniają się też liczby transformacji.
Spakuj i wgraj
Spakuj zawartość folderu z kafelkami, a nie sam folder: foldery z archiwum zostają zachowane, więc tiles/ trafiłoby do każdego linku:
# tiles/0/0/0.png, tiles/1/0/0.png, ... tiles/5/31/31.png
cd tiles && zip -r ../my-map.zip .FiveDock rozpakowuje archiwa .zip i .rar przy wgrywaniu. W panelu otwórz Pliki, utwórz folder maps/my-map i wejdź do niego, kliknij Wyślij plik i upuść zipa. Opcja Zachowaj nazwy plików z archiwów .zip i .rar jest domyślnie włączona; zostaw tak. Archiwum zawiera same foldery zoomu, bez tiles/, więc każdy kafelek staje się plikiem ze stałym kluczem, np. maps/my-map/3/2/1.png. To samo przez API, z tokenem, który ma 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"W odpowiedzi dostajesz listę zapisanych plików w uploaded i wszystko, co zostało pominięte, w skipped, razem z powodem.
Duże zestawy kafelków
Archiwum może mieć do 5000 plików. Podstawowa mapa od zoomu 0 do 5 to 1365 kafelków i mieści się w jednym. Jeden poziom głębiej sam zoom 6 dokłada 4096 i całość już się nie mieści: wgraj ten poziom osobnym archiwum (uruchomione z tego samego folderu zachowuje folder zoomu w kluczach). Każdy kolejny poziom jest znowu cztery razy większy; tak duże zestawy skopiuj w całości przez endpoint S3 rclone’em:
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 16Podepnij swoje kafelki w Leaflecie
Każdy kafelek ma teraz link, który się nie zmienia: https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png, gdzie <your-workspace> to slug twojego środowiska z panelu. Wstaw go do warstwy kafelków:
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);Trafił się zepsuty kafelek? Wgraj poprawiony z tym samym kluczem, np. maps/my-map/4/3/2.png. Zastąpi stary plik pod tym samym linkiem, a gracze zobaczą go w ciągu mniej więcej pięciu minut, gdy wygaśnie ich kopia w cache.
I to cała mapa: sześć części app.js z góry, po kolei, w jednym pliku. ensure resource’a, wejdź na serwer i wpisz /map. Te same linki z kluczem sprawdzą się przy wszystkim, co pokazuje twoje UI, od obrazków przedmiotów po mugshoty.