Przejdź do treści
Blog

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

10 min czytania
  • NUI
  • Leaflet
  • Lua
  • Maps
Na tej stronie

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:

  • śledzi gracza, a pozycję dostaje z client Lua cztery razy na sekundę,
  • pokazuje blipy w miejscach podanych w koordynatach z gry,
  • rysuje trasę między dwoma punktami i okrągłą strefę,
  • po kliknięciu odsyła koordynaty z gry do Lua, gdzie ustawiamy waypoint.

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.

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 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ł:

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;
}

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:

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 };
}

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:

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);

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:

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);
}

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ę:

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;
});

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:

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);

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:

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() 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

  • Ładują się tylko widoczne kafelki. Leaflet pobiera kafelki w widoku przy aktualnym zoomie plus mały margines, nigdy całą piramidę, więc otwarcie mapy to kilka małych obrazków, a nie megabajty.
  • Przeglądarka je cache’uje. NUI to Chromium ze zwykłym cache HTTP. Linki z kluczem w FiveDock (/o/…) mogą być cache’owane przez 5 minut, potem są rewalidowane przez ETag, więc niezmieniony kafelek kosztuje malutkie 304 zamiast pobrania.
  • Nic nie jedzie z resource’em. Kafelków nie ma w files, więc nie dokładają nic do tego, co gracze pobierają przy wejściu na serwer.
  • Nie zasypuj NUI wiadomościami. Cztery aktualizacje pozycji na sekundę wyglądają na mapie płynnie. Wysyłanie ich co klatkę tylko niepotrzebnie zajmuje Lua i NUI.
  • Rysuj na canvasie. preferCanvas: true rysuje circle markery, linie i strefy na jednym canvasie zamiast setek elementów SVG.
  • Ukrywaj, nie buduj od nowa. Trzymaj jedną mapę i przełączaj jej widoczność. Nowa mapa przy każdym otwarciu wyrzuca kafelki i markery, które już masz.

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:

bash
# 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:

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"

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:

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

Podepnij 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:

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);

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.

Wszystkie artykuły