Interaktive GTA V Karte für FiveM NUI mit Leaflet
Zoombare GTA V Karte im FiveM NUI mit Leaflet: CRS für Spielkoordinaten, Blips, Live-Spielerposition, Routen, Zonen und Klick auf die Karte für Waypoints.
Von Termi
- NUI
- Leaflet
- Lua
- Maps
Auf dieser Seite
- Was du baust
- Die Resource: fxmanifest und Dateien
- Leaflet vom CDN oder gebündelt
- Spielkoordinaten auf einer Leaflet-Karte
- Die GTA-V-Kacheln hinzufügen
- Marker und Blips aus Spielkoordinaten
- Die Position des Spielers anzeigen
- Eine Route und eine Zone zeichnen
- Auf die Karte klicken, GTA-Koordinaten in Lua bekommen
- Schnell halten
- Häufige Fragen
- Wie rechne ich GTA-V-Koordinaten für eine Leaflet-Karte um?
- Warum ist meine Leaflet-Karte im FiveM-NUI grau?
- Warum bekommt meine NUI-Seite ein 404 für ihre eigenen Dateien?
- Wo bekomme ich GTA-V-Kartenkacheln für Leaflet?
- Eigene Kartenkacheln auf FiveDock hosten
- Zippen und hochladen
- Leaflet auf deine Kacheln zeigen lassen
Die meisten FiveM-Karten-UIs beginnen gleich: Jemand macht einen Screenshot der Pausenmenü-Karte, packt ihn in ein <img> und kämpft einen Abend lang mit Pixelversätzen. Leaflet kann ein riesiges Bild bereits als zoombare Kacheln anzeigen, Marker, Linien und Kreise zeichnen und dir sagen, wo der Nutzer geklickt hat. Es fehlt nur ein Koordinatensystem, das zu GTA V passt.
Diese Anleitung baut eine NUI-Karte für eine FiveM-Resource von Grund auf: das Leaflet-Setup, das CRS, das Spielkoordinaten umrechnet, Blips, die Live-Spielerposition aus Lua, eine Route, eine Zone und einen Klick auf die Karte, der einen Waypoint setzt. Die GTA-V-Kacheln liegen auf dem FiveDock-CDN und sind kostenlos nutzbar, du bekommst also eine funktionierende Karte, ohne selbst etwas zu hosten.
Was du baust
Eine Resource mit einer NUI-Seite, die eine große GTA-V-Karte öffnet, wenn du /map eingibst. Die Karte:
Alles nutzt Leaflet 1.9, das im NUI-Browser problemlos läuft.
Die Resource: fxmanifest und Dateien
Eine NUI-Resource ist eine normale Resource mit einer ui_page. Jede Datei, die die Seite aus der Resource lädt, muss außerdem unter files stehen, sonst bekommt der NUI-Browser dafür ein 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 vom CDN oder gebündelt
Am schnellsten startest du, indem du Leaflet von einem CDN lädst. Der NUI-Browser lädt Remote-Scripts und cached sie wie jeder andere Browser. Pinne die Version und behalte die Integrity-Hashes, damit eine auf dem CDN veränderte Datei nie ausgeführt wird:
<!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;
}Wenn du dich nicht von einem fremden CDN abhängig machen willst, bündele es: Lade leaflet.js und leaflet.css nach html/vendor/ herunter, lass die beiden Tags darauf zeigen und füge 'html/vendor/*' zu files hinzu. Ist dein NUI eine Vite- oder React-App, installiere Leaflet mit npm install leaflet und importiere es; der Rest dieser Anleitung bleibt gleich.
Die Standard-Marker-Pins von Leaflet sind Bilder, die neben seinem CSS liegen, und in einem NUI müssen auch sie in files stehen. Diese Anleitung nutzt Circle-Marker, die gar keine Bilder brauchen.
Spielkoordinaten auf einer Leaflet-Karte
Leaflet denkt in Breiten- und Längengraden auf einer runden Erde. GTA V ist flach und in Metern gemessen, x wächst nach Osten und y nach Norden. L.CRS.Simple ist Leaflets flaches Koordinatensystem. Wir behalten seine Projektion L.Projection.LonLat, die den Längengrad als x und den Breitengrad als y nimmt, und tauschen eine Transformation ein, die Spielkoordinaten auf die Kartenkacheln ausrichtet:
// 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 };
}Was die Zahlen bedeuten: Bei Zoom 0 ist die ganze Karte die erste Kachel, 256 × 256 Pixel, und eine Spielposition (x, y) landet auf dem Pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8). Jede Zoomstufe verdoppelt das (scale ist 2 hoch Zoom), bei Zoom 5 ist die Karte also 8.192 Pixel breit. Der y-Faktor ist negativ, weil Spiel-y nach Norden zeigt, Pixel-y aber nach unten. Die FiveDock-Kacheln sind genau auf diese Kalibrierung zugeschnitten.
Eine Regel zum Merken
Leaflet erwartet [lat, lng], eine Spielposition geht also als [y, x] hinein. Rufe überall toLatLng(x, y) auf, dann vertauschst du die beiden nie aus Versehen.
Die GTA-V-Kacheln hinzufügen
Erstelle die Karte mit dem CRS und füge den Tile-Layer hinzu. FiveDock hostet ein GTA-V-Kachelset, das jeder nutzen darf, auf den Zoomstufen 0 bis 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);Zwei Optionen leisten die Hauptarbeit. bounds ist bei diesem CRS wichtig: infinite: true heißt, dass Leaflet nicht weiß, wo die Welt endet, und ohne Bounds Kacheln weit außerhalb der Karte anfragen würde, die nur als 404 zurückkommen können. Mit maxNativeZoom können Spieler eine Stufe über die letzte Kachelstufe hinaus zoomen: Leaflet skaliert die Kacheln von Zoom 5 hoch, statt nicht existierende anzufragen.
Marker und Blips aus Spielkoordinaten
Alles, dessen Spielkoordinaten du kennst, kann auf die Karte. Mit preferCanvas werden Circle-Marker auf einem einzigen Canvas gezeichnet, sodass auch Hunderte davon flüssig bleiben:
// 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);
}Die Koordinaten sind ungefähre Werte; stell dich im Spiel an eine Stelle und gib GetEntityCoords(PlayerPedId()) aus, um genaue zu bekommen. Wenn du lieber Icons willst, funktioniert L.marker mit einem L.icon, das auf ein Bild in deinem html/-Ordner zeigt, genauso, solange das Bild in files steht.
Die Position des Spielers anzeigen
Die NUI-Seite kann den Spielzustand nicht lesen, also muss das Client-Lua ihn schicken. SendNUIMessage liefert eine Tabelle als message-Event an die Seite. Öffne die Karte per Command und sende, solange sie offen ist, viermal pro Sekunde die Position:
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;
});Beachte den Aufruf von map.invalidateSize(). Leaflet misst seinen Container, wenn die Karte erstellt wird, und unserer startet versteckt. Ohne diesen Aufruf bekommst du eine graue Karte mit einer einzelnen Kachel in der Ecke.
Eine Route und eine Zone zeichnen
Linien und Kreise nehmen dieselben umgerechneten Koordinaten. Eine Route ist eine Polyline durch Spielpositionen, eine Zone ein Kreis mit einem Radius in Spieleinheiten:
// 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);Wenn das CRS nicht die Erde ist, misst L.circle seinen Radius in den Einheiten des CRS selbst, radius: 80 sind im Spiel also 80 Meter. Eine echte Route aus deinen eigenen Waypoints oder einem GPS-Script ist einfach eine längere Liste von Punkten.
Auf die Karte klicken, GTA-Koordinaten in Lua bekommen
Ein Klick liefert ein LatLng, und mit unserem CRS ist sein lng das Spiel-x und sein lat das Spiel-y. Schicke es an einen NUI-Callback:
// 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() steht auf jeder NUI-Seite zur Verfügung und gibt den Namen deiner Resource zurück. Das + 0.0 stellt sicher, dass die Werte als Floats bei der Native ankommen. Dasselbe Muster funktioniert für alles, was du auf einer Karte auswählen willst: einen Spawnpunkt, einen Abgabeort, einen Tatort für dein MDT.
Schnell halten
Häufige Fragen
Wie rechne ich GTA-V-Koordinaten für eine Leaflet-Karte um?
Nimm L.CRS.Simple mit seiner Projektion L.Projection.LonLat und deiner eigenen, auf die Kacheln kalibrierten L.Transformation, und gib jede Spielposition als [y, x] an, weil Leaflet den Breitengrad zuerst nimmt. Mit den FiveDock-Kacheln landet eine Spielposition (x, y) bei Zoom 0 auf dem Pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8), und jede Zoomstufe verdoppelt das. Ein kleiner toLatLng(x, y)-Helper verhindert, dass du die beiden vertauschst.
Warum ist meine Leaflet-Karte im FiveM-NUI grau?
Leaflet misst seinen Container, wenn die Karte erstellt wird, und eine NUI-Karte startet meist versteckt. Sie bekommt dann keine Größe und zeigt eine graue Fläche mit einer Kachel in der Ecke. Rufe map.invalidateSize() direkt auf, nachdem du die Karte zeigst. Füllt sich die Konsole mit 404-Fehlern für Kacheln, setze bounds am Tile-Layer, damit Leaflet keine Kacheln außerhalb der Karte mehr anfragt.
Warum bekommt meine NUI-Seite ein 404 für ihre eigenen Dateien?
Jede Datei, die die NUI-Seite aus der Resource lädt, also Scripts, Stylesheets und Bilder gleichermaßen, muss in der fxmanifest.lua unter files stehen, neben ui_page. Eine Datei, die nicht aufgeführt ist, wird nie an den Client geschickt, der NUI-Browser bekommt dafür also ein 404. Auch Leaflets Standard-Markerbilder zählen dazu, deshalb nutzt diese Anleitung Circle-Marker.
Wo bekomme ich GTA-V-Kartenkacheln für Leaflet?
FiveDock hostet ein GTA-V-Kachelset auf den Zoomstufen 0 bis 5, das jeder in einem FiveM-NUI nutzen darf, ohne selbst etwas zu hosten. Für einen eigenen Stil, etwa einen Satelliten-Render oder eine Karte mit eigenen Straßen, schneide {z}/{x}/{y}-Kacheln mit einem Tool wie gdal2tiles.py -p raster --xyz und lege sie auf ein CDN.
Eigene Kartenkacheln auf FiveDock hosten
Die geteilten Kacheln decken das Basisspiel ab. Hast du einen eigenen Stil (einen Satelliten-Render, ein dunkles Theme, eine Karte mit deinen eigenen Straßen und MLOs), hostest du ihn genauso. Leaflet erwartet einen Ordner pro Zoomstufe, darin einen Ordner pro Spalte und ein Bild pro Kachel: {z}/{x}/{y}.png (oder .jpg, wie bei der Basiskarte). Jedes Tool, das XYZ-Kacheln schreibt, funktioniert, zum Beispiel gdal2tiles.py -p raster --xyz. Behalte den Bildausschnitt der Basiskarte bei (dasselbe Gebiet der Welt, ein Bild von 8.192 × 8.192 Pixeln bei Zoom 5, das in der oberen linken Ecke der Kacheln beginnt), dann passt die Kalibrierung von oben weiterhin; deckt dein Bild ein anderes Gebiet ab oder liegt es auf einer anderen Zoomstufe, ändern sich die Zahlen der Transformation mit.
Zippen und hochladen
Zippe den Inhalt des Kachelordners, nicht den Ordner selbst: Ordner in einem Archiv bleiben erhalten, tiles/ würde also in jedem Link landen:
# tiles/0/0/0.png, tiles/1/0/0.png, ... tiles/5/31/31.png
cd tiles && zip -r ../my-map.zip .FiveDock entpackt .zip- und .rar-Archive beim Upload. Öffne im Panel Dateien, lege den Ordner maps/my-map an und öffne ihn, klicke dann auf Datei hochladen und zieh das Zip hinein. Dateinamen behalten ist bei Archiven standardmäßig an; lass es an. Das Archiv enthält die Zoom-Ordner selbst, nicht tiles/, sodass jede Kachel zu einer Datei mit festem Key wird, etwa maps/my-map/3/2/1.png. Derselbe Upload über die API, mit einem Token, der den Scope files:write hat:
curl -X POST https://api.fivedock.dev/api/v3/file/archive \
-H "Authorization: Bearer fd_live_…" \
-F "[email protected]" \
-F "path=maps/my-map"Die Antwort listet jede gespeicherte Datei unter uploaded und alles, was übersprungen wurde, unter skipped, mit einem Grund.
Große Kachelsets
Ein Archiv kann bis zu 5.000 Dateien enthalten. Die Basiskarte von Zoom 0 bis 5 besteht aus 1.365 Kacheln und passt in eins. Schneidest du eine Stufe tiefer, bringt allein Zoom 6 noch 4.096 dazu, das Set passt dann nicht mehr hinein: Lege diese Stufe in ein eigenes Archiv (aus demselben Ordner gestartet, bleibt der Zoom-Ordner in den Keys erhalten). Jede weitere Stufe ist wieder viermal so groß; kopiere so große Sets komplett über den S3-Endpoint mit 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 16Leaflet auf deine Kacheln zeigen lassen
Jede Kachel hat jetzt einen Link, der sich nie ändert, https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png, wobei <your-workspace> der Slug deiner Organisation aus dem Panel ist. Setze ihn in den Tile-Layer ein:
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);Eine fehlerhafte Kachel gefunden? Lade eine korrigierte mit demselben Key hoch, zum Beispiel maps/my-map/4/3/2.png. Sie ersetzt die alte Datei hinter demselben Link, und die Spieler sehen sie nach etwa fünf Minuten, sobald ihre gecachte Kopie abläuft.
Das ist die ganze Karte: die sechs app.js-Teile von oben, der Reihe nach, in einer Datei. ensure die Resource, joine und tippe /map. Dieselben Links mit Key funktionieren für alles andere, was dein UI zeigt, von Item-Bildern bis zu Mugshots.