Files
bordervillegame/map_editor.js
T
2026-08-22 08:40:29 +02:00

2552 lines
88 KiB
JavaScript

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
// -------------------------------------------------------------
// ZUGRIFFSSCHUTZ: nur eingeloggte Admins dürfen den Map-Editor nutzen
// -------------------------------------------------------------
const token = localStorage.getItem("token");
// Voll-Admin ODER die Berechtigung "manage_maps" reicht - der Server prüft
// das final ab, authFetch() fängt fehlende Rechte sauber ab
let isAdmin = localStorage.getItem("isAdmin") === "true";
if (!token) {
alert("Bitte zuerst einloggen.");
location.href = "/index.html";
}
async function authFetch(url, options = {}) {
options.headers = { ...(options.headers || {}), "Authorization": "Bearer " + token };
const res = await fetch(url, options);
if (res.status === 401 || res.status === 403) {
alert("Sitzung abgelaufen oder kein Admin-Zugriff. Bitte erneut einloggen.");
location.href = "/index.html";
throw new Error("Nicht autorisiert");
}
return res;
}
const mapNameInput = document.getElementById("mapName");
const mapSelect = document.getElementById("mapSelect");
const newMapBtn = document.getElementById("newMapBtn");
const saveMapBtn = document.getElementById("saveMapBtn");
const modeSelect = document.getElementById("modeSelect");
// -------------------------------------------------------------
// Icon-Werkzeugleiste (steuert weiterhin das versteckte modeSelect,
// damit der restliche Code unverändert "modeSelect.value" lesen kann)
// -------------------------------------------------------------
const modeButtons = document.querySelectorAll(".mode-btn");
function setToolbarActive(modeValue) {
modeButtons.forEach(btn => {
btn.classList.toggle("active", btn.dataset.mode === modeValue);
});
}
modeButtons.forEach(btn => {
btn.addEventListener("click", () => {
modeSelect.value = btn.dataset.mode;
setToolbarActive(btn.dataset.mode);
});
});
setToolbarActive(modeSelect.value);
// -------------------------------------------------------------
// Ebenen-Sichtbarkeit
// -------------------------------------------------------------
const layerVisibility = {
object: true, shop: true, atm: true, garage: true, jobcenter: true,
gasstation: true, repairshop: true, house: true, jobpoint: true,
taxistand: true, hospital: true, prison: true, zone: true, impound: true,
firestation: true, drug_harvest: true, drug_process: true, drug_dealer: true,
clothingshop: true, insuranceoffice: true, plateoffice: true, trailershop: true
};
document.querySelectorAll(".layer-toggle").forEach(cb => {
cb.addEventListener("change", () => {
layerVisibility[cb.dataset.layer] = cb.checked;
render();
});
});
const doorTargetMap = document.getElementById("doorTargetMap");
const doorTargetX = document.getElementById("doorTargetX");
const doorTargetY = document.getElementById("doorTargetY");
const mapWidthInput = document.getElementById("mapWidth");
const mapHeightInput = document.getElementById("mapHeight");
const resizeMapBtn = document.getElementById("resizeMapBtn");
const tilePalette = document.getElementById("tilePalette");
const objectPalette = document.getElementById("objectPalette");
// -------------------------------------------------------------
// KAMERA / SCROLLEN (NEU) - für große/"unendliche" Maps
// -------------------------------------------------------------
const editorCam = { x: 0, y: 0 };
const panKeys = {};
const PAN_SPEED = 14; // Pixel pro Frame
document.addEventListener("keydown", e => {
if (["arrowup", "arrowdown", "arrowleft", "arrowright"].includes(e.key.toLowerCase())) {
panKeys[e.key.toLowerCase()] = true;
e.preventDefault();
}
});
document.addEventListener("keyup", e => {
panKeys[e.key.toLowerCase()] = false;
});
// Mittlere Maustaste (Mausrad gedrückt halten) zum Ziehen/Scrollen
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let camStart = { x: 0, y: 0 };
canvas.addEventListener("mousedown", e => {
if (e.button === 1) { // mittlere Maustaste
isDragging = true;
dragStart = { x: e.clientX, y: e.clientY };
camStart = { x: editorCam.x, y: editorCam.y };
e.preventDefault();
}
});
document.addEventListener("mousemove", e => {
if (!isDragging) return;
editorCam.x = clampCamX(camStart.x - (e.clientX - dragStart.x));
editorCam.y = clampCamY(camStart.y - (e.clientY - dragStart.y));
render();
});
document.addEventListener("mouseup", e => {
if (e.button === 1) isDragging = false;
});
canvas.addEventListener("auxclick", e => {
if (e.button === 1) e.preventDefault(); // verhindert Auto-Scroll-Icon bei Mittelklick
});
function clampCamX(x) {
const maxX = Math.max(0, cols * tileSize - canvas.width);
return Math.min(Math.max(0, x), maxX);
}
function clampCamY(y) {
const maxY = Math.max(0, rows * tileSize - canvas.height);
return Math.min(Math.max(0, y), maxY);
}
function panLoop() {
let moved = false;
if (panKeys["arrowup"]) { editorCam.y -= PAN_SPEED; moved = true; }
if (panKeys["arrowdown"]) { editorCam.y += PAN_SPEED; moved = true; }
if (panKeys["arrowleft"]) { editorCam.x -= PAN_SPEED; moved = true; }
if (panKeys["arrowright"]) { editorCam.x += PAN_SPEED; moved = true; }
if (moved) {
editorCam.x = clampCamX(editorCam.x);
editorCam.y = clampCamY(editorCam.y);
render();
updateCamLabel();
}
requestAnimationFrame(panLoop);
}
function updateCamLabel() {
const label = document.getElementById("camLabel");
if (label) {
label.textContent = `Kamera-Tile: (${Math.floor(editorCam.x / tileSize)}, ${Math.floor(editorCam.y / tileSize)})`;
}
}
function goToTile(tx, ty) {
editorCam.x = clampCamX(tx * tileSize - canvas.width / 2);
editorCam.y = clampCamY(ty * tileSize - canvas.height / 2);
render();
updateCamLabel();
}
let objectConfig = {};
let selectedObjectType = null;
let objects = [];
let tileConfig = {};
let selectedTile = 1;
let brushSize = 1;
const brushSizeSelect = document.getElementById("brushSize");
brushSizeSelect.addEventListener("change", () => {
brushSize = parseInt(brushSizeSelect.value);
});
function paintTileBrush(centerX, centerY, tileId) {
const half = Math.floor(brushSize / 2);
for (let dy = 0; dy < brushSize; dy++) {
for (let dx = 0; dx < brushSize; dx++) {
const x = centerX - half + dx;
const y = centerY - half + dy;
if (x < 0 || y < 0 || y >= tiles.length || x >= tiles[0].length) continue;
tiles[y][x] = tileId;
}
}
}
let tileSize = 32;
let tiles = [];
let tileRot = []; // Drehwinkel je Kachel: 0, 90, 180, 270
let doors = [];
let spawn = { x: 32, y: 32 };
let cols = 20;
let rows = 20;
let shops = []; // kommt jetzt aus der DB (admin API), nicht mehr aus der Map-Datei
let atms = [];
let garages = []; // kommt ebenfalls aus der DB
let jobcenters = []; // kommt ebenfalls aus der DB
let gasStations = []; // kommt ebenfalls aus der DB
let repairShops = []; // kommt ebenfalls aus der DB
let houses = []; // kommt ebenfalls aus der DB
let jobPoints = []; // kommt ebenfalls aus der DB
let taxiStands = []; // kommt ebenfalls aus der DB
let hospitals = []; // kommt ebenfalls aus der DB
let prisons = []; // kommt ebenfalls aus der DB
let impoundLots = []; // kommt ebenfalls aus der DB
let fireStations = []; // kommt ebenfalls aus der DB
let harvestSpots = []; // kommt ebenfalls aus der DB
let processSpots = []; // kommt ebenfalls aus der DB
let dealerSpots = []; // kommt ebenfalls aus der DB (Pool!)
let clothingShops = []; // kommt ebenfalls aus der DB
let insuranceOffices = []; // kommt ebenfalls aus der DB
let plateOffices = []; // kommt ebenfalls aus der DB
let trailerShops = []; // kommt ebenfalls aus der DB
let allDrugTypesForEditor = [];
fetch("/api/admin/drug_types", { headers: { "Authorization": "Bearer " + localStorage.getItem("token") } })
.then(res => res.json())
.then(data => { allDrugTypesForEditor = data.drugTypes || []; });
function pickDrugId() {
if (allDrugTypesForEditor.length === 0) {
alert("Keine Drogensorten angelegt. Erst in admin_drugs.html eine Sorte anlegen.");
return null;
}
const list = allDrugTypesForEditor.map(d => `${d.id} (${d.name})`).join("\n");
const id = prompt(`Welche Drogensorte?\n${list}`);
if (!id) return null;
const match = allDrugTypesForEditor.find(d => d.id === id.trim());
if (!match) {
alert("Unbekannte Sorten-ID.");
return null;
}
return match.id;
}
let territoryZones = []; // kommt ebenfalls aus der DB
let allJobsForEditor = []; // für Job-Auswahl bei Job-Garagen/Job-Punkten
// -------------------------------------------------------------
// Tiles laden
// -------------------------------------------------------------
const tileImageCache = {};
fetch("/api/tile_config")
.then(res => res.json())
.then(data => {
tileConfig = data;
Object.entries(tileConfig).forEach(([id, tile]) => {
if (tile.image) {
const img = new Image();
img.src = tile.image;
tileImageCache[id] = img;
}
});
buildTilePalette();
loadMapList();
newMap();
});
fetch("/api/object_config")
.then(res => res.json())
.then(data => {
objectConfig = data;
buildObjectPalette();
});
// -------------------------------------------------------------
// Tile-Palette bauen
// -------------------------------------------------------------
function buildTilePalette() {
tilePalette.innerHTML = "";
Object.entries(tileConfig).forEach(([id, tile]) => {
const swatch = document.createElement("div");
swatch.className = "tile-swatch" + (selectedTile === parseInt(id) ? " selected" : "");
swatch.dataset.id = id;
swatch.title = `#${id}: ${tile.name}${tile.collision ? " (blockiert)" : ""}`;
const bgStyle = tile.image
? `background-image:url('${tile.image}'); background-size:cover; image-rendering:pixelated;`
: `background:${tile.color};`;
swatch.innerHTML = `
<div class="color-box" style="${bgStyle}">
${tile.collision ? '<span class="collision-icon">🚫</span>' : ""}
</div>
<div class="tile-label">${tile.name}</div>
`;
swatch.onclick = () => {
selectedTile = parseInt(id);
tilePalette.querySelectorAll(".tile-swatch").forEach(el => el.classList.remove("selected"));
swatch.classList.add("selected");
};
tilePalette.appendChild(swatch);
});
}
// -------------------------------------------------------------
// Object-Palette bauen
// -------------------------------------------------------------
function buildObjectPalette() {
objectPalette.innerHTML = "";
Object.entries(objectConfig).forEach(([type, obj]) => {
const div = document.createElement("div");
div.style.width = "32px";
div.style.height = "32px";
div.style.background = obj.color;
div.style.border = selectedObjectType === type ? "3px solid #f5d90a" : "2px solid #000";
div.style.boxShadow = selectedObjectType === type ? "0 0 6px #f5d90a" : "none";
div.style.display = "inline-block";
div.style.margin = "4px";
div.style.cursor = "pointer";
div.title = `${type}: ${obj.name}`;
div.dataset.objType = type;
div.onclick = () => {
selectedObjectType = type;
objectPalette.querySelectorAll("div").forEach(el => {
el.style.border = "2px solid #000";
el.style.boxShadow = "none";
});
div.style.border = "3px solid #f5d90a";
div.style.boxShadow = "0 0 6px #f5d90a";
};
objectPalette.appendChild(div);
});
// Falls noch nichts ausgewählt ist, direkt den ersten Typ vorauswählen
if (!selectedObjectType) {
const first = objectPalette.querySelector("div");
if (first) first.click();
}
}
// -------------------------------------------------------------
// Neue Map
// -------------------------------------------------------------
function newMap() {
tiles = [];
tileRot = [];
for (let y = 0; y < rows; y++) {
tiles[y] = [];
tileRot[y] = [];
for (let x = 0; x < cols; x++) {
tiles[y][x] = 1;
tileRot[y][x] = 0;
}
}
doors = [];
objects = [];
atms = [];
shops = []; // wird erst befüllt, sobald die Map einen gespeicherten Namen hat
spawn = { x: 32, y: 32 };
editorCam.x = 0;
editorCam.y = 0;
updateCamLabel();
render();
}
newMapBtn.onclick = () => {
if (!confirm("Neue, leere Map erstellen? Nicht gespeicherte Änderungen an der aktuellen Map gehen verloren.")) {
return;
}
const name = prompt("Name für die neue Map (z.B. wald):");
if (!name) return;
mapNameInput.value = name.trim();
mapSelect.value = "";
newMap();
alert(`Leere Map "${name.trim()}" ist bereit zum Bearbeiten. Nicht vergessen: oben auf "Speichern" klicken, damit sie wirklich angelegt wird!`);
};
// -------------------------------------------------------------
// Map-Liste laden
// -------------------------------------------------------------
function loadMapList() {
fetch("/api/get_maps")
.then(res => res.json())
.then(data => {
mapSelect.innerHTML = `<option value="">Map laden...</option>`;
Object.keys(data.maps).forEach(name => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
mapSelect.appendChild(opt);
});
});
}
// -------------------------------------------------------------
// Shops für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadShopsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
shops = [];
render();
return;
}
const res = await authFetch("/api/admin/shops");
const data = await res.json();
shops = (data.shops || []).filter(s => s.world === world);
render();
}
// -------------------------------------------------------------
// Garagen für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadGaragesForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
garages = [];
render();
return;
}
const res = await authFetch("/api/admin/garages");
const data = await res.json();
garages = (data.garages || []).filter(g => g.world === world);
render();
}
// -------------------------------------------------------------
// Jobcenter für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadJobcentersForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
jobcenters = [];
render();
return;
}
const res = await authFetch("/api/admin/jobcenters");
const data = await res.json();
jobcenters = (data.jobcenters || []).filter(j => j.world === world);
render();
}
// -------------------------------------------------------------
// Tankstellen für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadGasStationsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
gasStations = [];
render();
return;
}
const res = await authFetch("/api/admin/gas_stations");
const data = await res.json();
gasStations = (data.gasStations || []).filter(g => g.world === world);
render();
}
// -------------------------------------------------------------
// Werkstätten für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadRepairShopsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
repairShops = [];
render();
return;
}
const res = await authFetch("/api/admin/repair_shops");
const data = await res.json();
repairShops = (data.repairShops || []).filter(r => r.world === world);
render();
}
// -------------------------------------------------------------
// Häuser für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadHousesForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
houses = [];
render();
return;
}
const res = await authFetch("/api/admin/houses");
const data = await res.json();
houses = (data.houses || []).filter(h => h.world === world);
render();
}
// -------------------------------------------------------------
// Job-Punkte für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadJobPointsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
jobPoints = [];
render();
return;
}
const res = await authFetch("/api/admin/job_points");
const data = await res.json();
jobPoints = (data.jobPoints || []).filter(jp => jp.world === world);
render();
}
// -------------------------------------------------------------
// Taxi-Stände für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadTaxiStandsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
taxiStands = [];
render();
return;
}
const res = await authFetch("/api/admin/taxi_stands");
const data = await res.json();
taxiStands = (data.taxiStands || []).filter(t => t.world === world);
render();
}
// -------------------------------------------------------------
// Krankenhäuser für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadHospitalsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
hospitals = [];
render();
return;
}
const res = await authFetch("/api/admin/hospitals");
const data = await res.json();
hospitals = (data.hospitals || []).filter(h => h.world === world);
render();
}
// -------------------------------------------------------------
// Gefängnisse für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadPrisonsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
prisons = [];
render();
return;
}
const res = await authFetch("/api/admin/prisons");
const data = await res.json();
prisons = (data.prisons || []).filter(p => p.world === world);
render();
}
// -------------------------------------------------------------
// Abschlepphöfe für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadImpoundLotsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
impoundLots = [];
render();
return;
}
const res = await authFetch("/api/admin/impound_lots");
const data = await res.json();
impoundLots = (data.lots || []).filter(l => l.world === world);
render();
}
// -------------------------------------------------------------
// Feuerwachen für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadFireStationsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
fireStations = [];
render();
return;
}
const res = await authFetch("/api/admin/fire_stations");
const data = await res.json();
fireStations = (data.stations || []).filter(s => s.world === world);
render();
}
// -------------------------------------------------------------
// Drogen-Standorte für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadHarvestSpotsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { harvestSpots = []; render(); return; }
const res = await authFetch("/api/admin/drug_harvest_spots");
const data = await res.json();
harvestSpots = (data.spots || []).filter(s => s.world === world);
render();
}
async function loadProcessSpotsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { processSpots = []; render(); return; }
const res = await authFetch("/api/admin/drug_process_spots");
const data = await res.json();
processSpots = (data.spots || []).filter(s => s.world === world);
render();
}
async function loadDealerSpotsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { dealerSpots = []; render(); return; }
const res = await authFetch("/api/admin/drug_dealer_spots");
const data = await res.json();
dealerSpots = (data.spots || []).filter(s => s.world === world);
render();
}
async function loadClothingShopsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { clothingShops = []; render(); return; }
const res = await authFetch("/api/admin/clothing_shops");
const data = await res.json();
clothingShops = (data.shops || []).filter(s => s.world === world);
render();
}
async function loadInsuranceOfficesForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { insuranceOffices = []; render(); return; }
const res = await authFetch("/api/admin/insurance_offices");
const data = await res.json();
insuranceOffices = (data.offices || []).filter(s => s.world === world);
render();
}
async function loadPlateOfficesForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { plateOffices = []; render(); return; }
const res = await authFetch("/api/admin/plate_offices");
const data = await res.json();
plateOffices = (data.offices || []).filter(s => s.world === world);
render();
}
async function loadTrailerShopsForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) { trailerShops = []; render(); return; }
const res = await authFetch("/api/admin/trailer_shops");
const data = await res.json();
trailerShops = (data.shops || []).filter(s => s.world === world);
render();
}
// -------------------------------------------------------------
// Territoriums-Zonen für die aktuelle Map aus der DB laden (NEU)
// -------------------------------------------------------------
async function loadTerritoryZonesForCurrentMap() {
const world = mapNameInput.value.trim();
if (!world) {
territoryZones = [];
render();
return;
}
const res = await authFetch("/api/admin/territory_zones");
const data = await res.json();
territoryZones = (data.zones || []).filter(z => z.world === world);
render();
}
// Jobliste laden (für Auswahl bei Job-Garage/Job-Punkt platzieren)
async function loadJobsForEditor() {
const res = await authFetch("/api/admin/jobs");
const data = await res.json();
allJobsForEditor = data.jobs || [];
}
loadJobsForEditor();
function pickJobId() {
if (allJobsForEditor.length === 0) {
alert("Keine Jobs angelegt. Erst in der Job-Verwaltung einen Job anlegen.");
return null;
}
const list = allJobsForEditor.map(j => `${j.id} = ${j.name}`).join("\n");
const idStr = prompt(`Welcher Job? (ID eingeben)\n\n${list}`);
if (!idStr) return null;
const id = Number(idStr);
if (!allJobsForEditor.some(j => j.id === id)) {
alert("Ungültige Job-ID.");
return null;
}
return id;
}
// -------------------------------------------------------------
// Map laden
// -------------------------------------------------------------
mapSelect.onchange = () => {
const name = mapSelect.value;
if (!name) return;
fetch("/api/get_maps")
.then(res => res.json())
.then(data => {
const map = data.maps[name];
mapNameInput.value = map.name;
tiles = map.tiles;
doors = map.doors || [];
objects = map.objects || [];
atms = map.atms || [];
spawn = map.spawn;
rows = tiles.length;
cols = tiles[0].length;
// tileRot: falls die Map noch keine Drehwinkel-Daten hat (ältere Karte), mit 0 auffüllen
if (map.tileRot && map.tileRot.length === rows) {
tileRot = map.tileRot;
} else {
tileRot = [];
for (let y = 0; y < rows; y++) {
tileRot[y] = [];
for (let x = 0; x < cols; x++) tileRot[y][x] = 0;
}
}
editorCam.x = 0;
editorCam.y = 0;
updateCamLabel();
render();
loadShopsForCurrentMap(); // Shops kommen aus der DB, nicht aus der Map-Datei
loadGaragesForCurrentMap(); // Garagen ebenfalls aus der DB
loadJobcentersForCurrentMap(); // Jobcenter ebenfalls aus der DB
loadGasStationsForCurrentMap(); // Tankstellen ebenfalls aus der DB
loadRepairShopsForCurrentMap(); // Werkstätten ebenfalls aus der DB
loadHousesForCurrentMap();
loadJobPointsForCurrentMap();
loadTaxiStandsForCurrentMap();
loadHospitalsForCurrentMap();
loadPrisonsForCurrentMap();
loadImpoundLotsForCurrentMap();
loadTerritoryZonesForCurrentMap();
loadFireStationsForCurrentMap();
loadHarvestSpotsForCurrentMap();
loadProcessSpotsForCurrentMap();
loadDealerSpotsForCurrentMap();
loadClothingShopsForCurrentMap();
loadInsuranceOfficesForCurrentMap();
loadPlateOfficesForCurrentMap();
loadTrailerShopsForCurrentMap();
});
};
// -------------------------------------------------------------
// Map speichern
// -------------------------------------------------------------
function buildMapData() {
const name = mapNameInput.value.trim();
return {
name,
spawn,
tiles,
tileRot,
doors,
objects,
atms
// shops NICHT mehr mitspeichern - die leben jetzt in der DB
};
}
saveMapBtn.onclick = () => {
const name = mapNameInput.value.trim();
if (!name) return alert("Map-Name fehlt!");
authFetch("/api/save_map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, data: buildMapData() })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
alert("Map gespeichert!");
loadMapList();
loadShopsForCurrentMap();
loadGaragesForCurrentMap();
loadJobcentersForCurrentMap();
loadGasStationsForCurrentMap();
loadRepairShopsForCurrentMap();
loadHousesForCurrentMap();
loadJobPointsForCurrentMap();
loadTaxiStandsForCurrentMap();
loadHospitalsForCurrentMap();
loadPrisonsForCurrentMap();
loadTerritoryZonesForCurrentMap();
loadImpoundLotsForCurrentMap();
loadFireStationsForCurrentMap();
loadHarvestSpotsForCurrentMap();
loadProcessSpotsForCurrentMap();
loadDealerSpotsForCurrentMap();
loadClothingShopsForCurrentMap();
loadInsuranceOfficesForCurrentMap();
loadPlateOfficesForCurrentMap();
loadTrailerShopsForCurrentMap();
} else {
alert("Fehler: " + data.error);
}
});
};
function saveMap() {
const name = mapNameInput.value.trim();
if (!name) return alert("Map-Name fehlt!");
authFetch("/api/save_map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, data: buildMapData() })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
alert("Map gespeichert!");
loadMapList();
} else {
alert("Fehler: " + data.error);
}
});
}
// -------------------------------------------------------------
// Map-Größe ändern
// -------------------------------------------------------------
resizeMapBtn.onclick = () => {
const newW = parseInt(mapWidthInput.value);
const newH = parseInt(mapHeightInput.value);
if (!newW || !newH) return;
const newTiles = [];
const newTileRot = [];
for (let y = 0; y < newH; y++) {
newTiles[y] = [];
newTileRot[y] = [];
for (let x = 0; x < newW; x++) {
newTiles[y][x] = tiles[y]?.[x] ?? 1;
newTileRot[y][x] = tileRot[y]?.[x] ?? 0;
}
}
tiles = newTiles;
tileRot = newTileRot;
cols = newW;
rows = newH;
render();
};
// -------------------------------------------------------------
// Canvas Click
// -------------------------------------------------------------
canvas.addEventListener("contextmenu", e => e.preventDefault());
let isPaintDragging = false;
let paintDragButton = 0;
canvas.addEventListener("mousedown", e => {
if (e.button === 1) return; // Mittelklick wird vom Dragging-Handler oben behandelt
e.preventDefault();
if (modeSelect.value === "tile") {
isPaintDragging = true;
paintDragButton = e.button;
}
handleCanvasClick(e);
});
canvas.addEventListener("mousemove", e => {
if (!isPaintDragging || modeSelect.value !== "tile") return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left + editorCam.x;
const my = e.clientY - rect.top + editorCam.y;
const tileX = Math.floor(mx / tileSize);
const tileY = Math.floor(my / tileSize);
if (tileX < 0 || tileY < 0 || tileX >= cols || tileY >= rows) return;
paintTileBrush(tileX, tileY, paintDragButton === 0 ? selectedTile : 0);
render();
});
document.addEventListener("mouseup", () => {
isPaintDragging = false;
});
function handleCanvasClick(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left + editorCam.x;
const my = e.clientY - rect.top + editorCam.y;
const tileX = Math.floor(mx / tileSize);
const tileY = Math.floor(my / tileSize);
if (tileX < 0 || tileY < 0 || tileX >= cols || tileY >= rows) return;
const mode = modeSelect.value;
// TILES
if (mode === "tile") {
paintTileBrush(tileX, tileY, e.button === 0 ? selectedTile : 0);
render();
return;
}
// DREHEN: zuerst prüfen, ob an dieser Stelle ein Objekt steht (das hat Vorrang),
// sonst wird stattdessen die Tile darunter gedreht
if (mode === "rotate") {
const ox = tileX * tileSize;
const oy = tileY * tileSize;
const obj = objects.find(o => o.x === ox && o.y === oy);
if (obj) {
const current = obj.rot || 0;
if (e.button === 0) obj.rot = (current + 90) % 360;
else if (e.button === 2) obj.rot = (current + 270) % 360;
render();
return;
}
if (tileY < 0 || tileX < 0 || tileY >= tiles.length || tileX >= tiles[0].length) return;
if (!tileRot[tileY]) tileRot[tileY] = [];
const current = tileRot[tileY][tileX] || 0;
if (e.button === 0) {
tileRot[tileY][tileX] = (current + 90) % 360;
} else if (e.button === 2) {
tileRot[tileY][tileX] = (current + 270) % 360;
}
render();
return;
}
// SPAWN
if (mode === "spawn") {
if (e.button === 0) {
spawn.x = tileX * tileSize;
spawn.y = tileY * tileSize;
render();
}
return;
}
// DOORS
if (mode === "door") {
if (e.button === 0) {
doors.push({
x: tileX * tileSize,
y: tileY * tileSize,
targetMap: doorTargetMap.value,
targetX: parseInt(doorTargetX.value),
targetY: parseInt(doorTargetY.value)
});
render();
}
if (e.button === 2) {
doors = doors.filter(d => !(d.x === tileX * tileSize && d.y === tileY * tileSize));
render();
}
return;
}
// OBJECTS
if (mode === "object") {
const ox = tileX * tileSize;
const oy = tileY * tileSize;
if (e.button === 0) {
if (!selectedObjectType) {
alert("Bitte zuerst einen Objekt-Typ in der Palette unten auswählen.");
return;
}
const newObj = { type: selectedObjectType, x: ox, y: oy };
const cfg = objectConfig[selectedObjectType];
if (cfg && cfg.action === "toggle_gate") {
const side = prompt(
"Einbahn-Sperre für dieses Tor? Leer lassen = von beiden Seiten bedienbar.\n" +
"Gültige Werte: top, bottom, left, right\n" +
"(die gesperrte Seite kann das Tor NICHT öffnen/schließen)",
""
);
const trimmed = (side || "").trim().toLowerCase();
if (["top", "bottom", "left", "right"].includes(trimmed)) {
newObj.oneWaySide = trimmed;
}
}
objects.push(newObj);
render();
}
if (e.button === 2) {
objects = objects.filter(o => !(o.x === ox && o.y === oy));
render();
}
return;
}
// SHOPS (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "shop") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = shops.find(s => s.x === tileX && s.y === tileY);
if (existing) {
alert(`Hier steht schon Shop "#${existing.id}". Items verwaltest du im Admin-Panel.`);
return;
}
const name = prompt("Shop-Name:");
if (!name) return;
authFetch("/api/admin/shops", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadShopsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = shops.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Shop "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/shops/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadShopsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// GARAGEN (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "garage") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = garages.find(g => g.x === tileX && g.y === tileY);
if (existing) return;
const name = prompt("Garage-Name:");
if (!name) return;
const isJobGarage = confirm("Soll das eine JOB-Garage (Fuhrpark) sein?\nOK = Job-Garage, Abbrechen = normale private Garage");
let jobId = null;
if (isJobGarage) {
jobId = pickJobId();
if (jobId === null) return;
}
authFetch("/api/admin/garages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY, jobId })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadGaragesForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = garages.find(g => g.x === tileX && g.y === tileY);
if (!existing) return;
if (!confirm(`Garage "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/garages/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadGaragesForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// HÄUSER (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "house") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = houses.find(h => h.x === tileX && h.y === tileY);
if (existing) return;
const name = prompt("Haus-Name:");
if (!name) return;
const priceStr = prompt("Kaufpreis ($):", "1000");
const price = Number(priceStr);
if (isNaN(price) || price <= 0) return;
authFetch("/api/admin/houses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY, price })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadHousesForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = houses.find(h => h.x === tileX && h.y === tileY);
if (!existing) return;
if (!confirm(`Haus "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/houses/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadHousesForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// JOB-PUNKTE (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "jobpoint") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = jobPoints.find(jp => jp.x === tileX && jp.y === tileY);
if (existing) return;
const jobId = pickJobId();
if (jobId === null) return;
const name = prompt("Name des Zielpunkts (z.B. 'Ladung abholen'):");
if (!name) return;
const itemId = prompt(
"Item-ID, das hier abgeholt/abgeliefert wird (muss in der Item-Verwaltung existieren):",
"package"
);
if (!itemId) return;
const isDropoff = confirm("Ist das ein LIEFERPUNKT (Item abgeben)?\nOK = Lieferpunkt, Abbrechen = Abholpunkt");
const kind = isDropoff ? "dropoff" : "pickup";
let reward = 50;
if (isDropoff) {
const rewardStr = prompt("Belohnung pro Lieferung ($):", "50");
reward = Number(rewardStr) || 50;
}
authFetch("/api/admin/job_points", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId, name, world, x: tileX, y: tileY, kind, reward, itemId })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadJobPointsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = jobPoints.find(jp => jp.x === tileX && jp.y === tileY);
if (!existing) return;
if (!confirm(`Job-Punkt "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/job_points/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadJobPointsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// TERRITORIUMS-ZONEN (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "zone") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = territoryZones.find(z => z.x === tileX && z.y === tileY);
if (existing) return;
const name = prompt("Name der Zone (z.B. 'Hafenviertel'):");
if (!name) return;
authFetch("/api/admin/territory_zones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadTerritoryZonesForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = territoryZones.find(z => z.x === tileX && z.y === tileY);
if (!existing) return;
if (!confirm(`Zone "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/territory_zones/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadTerritoryZonesForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// DROGEN-ANBAUSTELLEN (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "drug_harvest") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = harvestSpots.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const drugId = pickDrugId();
if (!drugId) return;
const name = prompt("Name der Anbaustelle:", "Anbaustelle");
if (!name) return;
authFetch("/api/admin/drug_harvest_spots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drugId, name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadHarvestSpotsForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = harvestSpots.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Anbaustelle "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/drug_harvest_spots/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadHarvestSpotsForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
// DROGEN-LABORE (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "drug_process") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = processSpots.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const drugId = pickDrugId();
if (!drugId) return;
const name = prompt("Name des Labors:", "Labor");
if (!name) return;
authFetch("/api/admin/drug_process_spots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drugId, name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadProcessSpotsForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = processSpots.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Labor "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/drug_process_spots/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadProcessSpotsForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
// DROGEN-VERKAUFSORTE-POOL (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "drug_dealer") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = dealerSpots.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const drugId = pickDrugId();
if (!drugId) return;
const name = prompt("Name des Verkaufsorts:", "Verkaufsstelle");
if (!name) return;
authFetch("/api/admin/drug_dealer_spots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drugId, name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadDealerSpotsForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = dealerSpots.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Verkaufsort "#${existing.id}" wirklich aus dem Pool löschen?`)) return;
authFetch("/api/admin/drug_dealer_spots/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadDealerSpotsForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
// KLEIDUNGSLÄDEN (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "trailershop") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = trailerShops.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const name = prompt("Name der Anhänger-Shop:", "Anhänger-Shop");
if (!name) return;
authFetch("/api/admin/trailer_shops", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadTrailerShopsForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = trailerShops.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Anhänger-Shop "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/trailer_shops/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadTrailerShopsForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
if (mode === "insuranceoffice") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = insuranceOffices.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const name = prompt("Name der Versicherung:", "Versicherung");
if (!name) return;
authFetch("/api/admin/insurance_offices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadInsuranceOfficesForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = insuranceOffices.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Versicherung "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/insurance_offices/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadInsuranceOfficesForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
if (mode === "plateoffice") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = plateOffices.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const name = prompt("Name der Zulassungsstelle:", "Kfz-Zulassungsstelle");
if (!name) return;
authFetch("/api/admin/plate_offices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadPlateOfficesForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = plateOffices.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Zulassungsstelle "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/plate_offices/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadPlateOfficesForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
if (mode === "clothingshop") {
const world = mapNameInput.value.trim();
if (!world) { alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern."); return; }
if (e.button === 0) {
const existing = clothingShops.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const name = prompt("Name des Kleidungsladens:", "Kleidungsladen");
if (!name) return;
authFetch("/api/admin/clothing_shops", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
}).then(res => res.json()).then(data => {
if (data.ok) loadClothingShopsForCurrentMap();
else alert("Fehler: " + (data.error || "unbekannt"));
});
}
if (e.button === 2) {
const existing = clothingShops.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Kleidungsladen "#${existing.id}" wirklich löschen?`)) return;
authFetch("/api/admin/clothing_shops/" + existing.id, { method: "DELETE" })
.then(res => res.json()).then(data => {
if (data.ok) loadClothingShopsForCurrentMap();
else alert("Löschen fehlgeschlagen.");
});
}
return;
}
// FEUERWACHEN (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "firestation") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = fireStations.find(s => s.x === tileX && s.y === tileY);
if (existing) return;
const name = prompt("Feuerwachen-Name:");
if (!name) return;
authFetch("/api/admin/fire_stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadFireStationsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = fireStations.find(s => s.x === tileX && s.y === tileY);
if (!existing) return;
if (!confirm(`Feuerwache "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/fire_stations/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadFireStationsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// ABSCHLEPPHÖFE (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "impound") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = impoundLots.find(l => l.x === tileX && l.y === tileY);
if (existing) return;
const name = prompt("Abschlepphof-Name:");
if (!name) return;
authFetch("/api/admin/impound_lots", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadImpoundLotsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = impoundLots.find(l => l.x === tileX && l.y === tileY);
if (!existing) return;
if (!confirm(`Abschlepphof "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/impound_lots/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadImpoundLotsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// GEFÄNGNISSE (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "prison") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = prisons.find(p => p.x === tileX && p.y === tileY);
if (existing) return;
const name = prompt("Gefängnis-Name:");
if (!name) return;
authFetch("/api/admin/prisons", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadPrisonsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = prisons.find(p => p.x === tileX && p.y === tileY);
if (!existing) return;
if (!confirm(`Gefängnis "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/prisons/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadPrisonsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// KRANKENHÄUSER (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "hospital") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = hospitals.find(h => h.x === tileX && h.y === tileY);
if (existing) return;
const name = prompt("Krankenhaus-Name:");
if (!name) return;
authFetch("/api/admin/hospitals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadHospitalsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = hospitals.find(h => h.x === tileX && h.y === tileY);
if (!existing) return;
if (!confirm(`Krankenhaus "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/hospitals/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadHospitalsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// TAXI-STÄNDE (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "taxistand") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = taxiStands.find(t => t.x === tileX && t.y === tileY);
if (existing) return;
const name = prompt("Taxi-Stand-Name:");
if (!name) return;
authFetch("/api/admin/taxi_stands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadTaxiStandsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = taxiStands.find(t => t.x === tileX && t.y === tileY);
if (!existing) return;
if (!confirm(`Taxi-Stand "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/taxi_stands/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadTaxiStandsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// JOBCENTER (DB-basiert: Linksklick = anlegen, Rechtsklick = löschen)
if (mode === "jobcenter") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = jobcenters.find(j => j.x === tileX && j.y === tileY);
if (existing) return;
const name = prompt("Jobcenter-Name:");
if (!name) return;
authFetch("/api/admin/jobcenters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadJobcentersForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = jobcenters.find(j => j.x === tileX && j.y === tileY);
if (!existing) return;
if (!confirm(`Jobcenter "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/jobcenters/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadJobcentersForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// TANKSTELLEN (DB-basiert: Linksklick = anlegen/Preis ändern, Rechtsklick = löschen)
if (mode === "gasstation") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = gasStations.find(g => g.x === tileX && g.y === tileY);
if (existing) {
const priceStr = prompt("Neuer Preis pro Liter ($):", existing.price);
if (priceStr === null) return;
const price = Number(priceStr);
if (isNaN(price) || price <= 0) return;
authFetch("/api/admin/gas_stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: existing.id, price })
})
.then(res => res.json())
.then(data => {
if (data.ok) loadGasStationsForCurrentMap();
else alert("Fehler beim Aktualisieren.");
});
return;
}
const name = prompt("Tankstellen-Name:");
if (!name) return;
const priceStr = prompt("Preis pro Liter ($):", "2.00");
const price = Number(priceStr);
if (isNaN(price) || price <= 0) return;
authFetch("/api/admin/gas_stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY, price })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadGasStationsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = gasStations.find(g => g.x === tileX && g.y === tileY);
if (!existing) return;
if (!confirm(`Tankstelle "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/gas_stations/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadGasStationsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// WERKSTÄTTEN (DB-basiert: Linksklick = anlegen/Preis ändern, Rechtsklick = löschen)
if (mode === "repairshop") {
const world = mapNameInput.value.trim();
if (!world) {
alert("Bitte zuerst einen Map-Namen eingeben und die Map speichern.");
return;
}
if (e.button === 0) {
const existing = repairShops.find(r => r.x === tileX && r.y === tileY);
if (existing) {
const priceStr = prompt("Neuer Preis pro Reparatur-Punkt ($):", existing.price_per_point);
if (priceStr === null) return;
const price = Number(priceStr);
if (isNaN(price) || price <= 0) return;
authFetch("/api/admin/repair_shops", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: existing.id, pricePerPoint: price })
})
.then(res => res.json())
.then(data => {
if (data.ok) loadRepairShopsForCurrentMap();
else alert("Fehler beim Aktualisieren.");
});
return;
}
const name = prompt("Werkstatt-Name:");
if (!name) return;
const priceStr = prompt("Preis pro Reparatur-Punkt ($):", "5.00");
const price = Number(priceStr);
if (isNaN(price) || price <= 0) return;
authFetch("/api/admin/repair_shops", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, world, x: tileX, y: tileY, pricePerPoint: price })
})
.then(res => res.json())
.then(data => {
if (data.ok) {
loadRepairShopsForCurrentMap();
} else {
alert("Fehler: " + (data.error || "unbekannt"));
}
});
}
if (e.button === 2) {
const existing = repairShops.find(r => r.x === tileX && r.y === tileY);
if (!existing) return;
if (!confirm(`Werkstatt "#${existing.id}" hier wirklich löschen?`)) return;
authFetch("/api/admin/repair_shops/" + existing.id, { method: "DELETE" })
.then(res => res.json())
.then(data => {
if (data.ok) {
loadRepairShopsForCurrentMap();
} else {
alert("Löschen fehlgeschlagen.");
}
});
}
return;
}
// ATMS (weiterhin in der Map-Datei gespeichert)
if (mode === "atm") {
if (e.button === 0) {
const existing = atms.find(a => a.x === tileX && a.y === tileY);
if (existing) return;
atms.push({
id: "atm_" + Date.now(),
x: tileX,
y: tileY
});
render();
}
if (e.button === 2) {
atms = atms.filter(a => !(a.x === tileX && a.y === tileY));
render();
}
return;
}
}
// -------------------------------------------------------------
// Render Shops / ATMs
// -------------------------------------------------------------
function renderShops() {
shops.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "yellow";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "10px Arial";
ctx.fillText("#" + s.id, px + 2, py + 12);
});
}
function renderATMs() {
atms.forEach(a => {
const px = a.x * tileSize - editorCam.x;
const py = a.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "blue";
ctx.fillRect(px, py, tileSize, tileSize);
});
}
function renderGarages() {
garages.forEach(g => {
const px = g.x * tileSize - editorCam.x;
const py = g.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "green";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "10px Arial";
ctx.fillText("#" + g.id, px + 2, py + 12);
});
}
function renderJobcenters() {
jobcenters.forEach(j => {
const px = j.x * tileSize - editorCam.x;
const py = j.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "purple";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "10px Arial";
ctx.fillText("#" + j.id, px + 2, py + 12);
});
}
function renderGasStations() {
gasStations.forEach(g => {
const px = g.x * tileSize - editorCam.x;
const py = g.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#e67e22";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + g.id, px + 2, py + 12);
ctx.fillText(g.price.toFixed(2) + "$", px + 2, py + 22);
});
}
function renderRepairShops() {
repairShops.forEach(r => {
const px = r.x * tileSize - editorCam.x;
const py = r.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#7f8c8d";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + r.id, px + 2, py + 12);
ctx.fillText(r.price_per_point.toFixed(2) + "$", px + 2, py + 22);
});
}
function renderHouses() {
houses.forEach(h => {
const px = h.x * tileSize - editorCam.x;
const py = h.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = h.owner_id ? "#8e6b4a" : "#c9a876";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText("#" + h.id, px + 2, py + 12);
ctx.fillText(h.price + "$", px + 2, py + 22);
});
}
function renderJobPoints() {
jobPoints.forEach(jp => {
const px = jp.x * tileSize - editorCam.x;
const py = jp.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = jp.kind === "dropoff" ? "#3498db" : "#2ecc71";
ctx.beginPath();
ctx.arc(px + tileSize / 2, py + tileSize / 2, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "black";
ctx.stroke();
ctx.fillStyle = "black";
ctx.font = "9px Arial";
const label = jp.kind === "dropoff" ? `Job ${jp.job_id}: ${jp.name} (${jp.reward}$)` : `Job ${jp.job_id}: ${jp.name}`;
ctx.fillText(label, px - 4, py + 34);
});
}
function renderTaxiStands() {
taxiStands.forEach(t => {
const px = t.x * tileSize - editorCam.x;
const py = t.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#f5d90a";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText("#" + t.id + " " + t.name, px - 4, py + 44);
});
}
function renderHospitals() {
hospitals.forEach(h => {
const px = h.x * tileSize - editorCam.x;
const py = h.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#e74c3c";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText("#" + h.id + " " + h.name, px - 4, py + 44);
});
}
function renderPrisons() {
prisons.forEach(p => {
const px = p.x * tileSize - editorCam.x;
const py = p.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#555555";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + p.id + " " + p.name, px - 4, py + 44);
});
}
function renderImpoundLots() {
impoundLots.forEach(l => {
const px = l.x * tileSize - editorCam.x;
const py = l.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#b8860b";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + l.id + " " + l.name, px - 4, py + 44);
});
}
function renderFireStations() {
fireStations.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#c0392b";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + s.id + " " + s.name, px - 4, py + 44);
});
}
function renderHarvestSpots() {
harvestSpots.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#2ecc71";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name} (${s.drug_id})`, px - 4, py + 44);
});
}
function renderProcessSpots() {
processSpots.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#8e44ad";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name} (${s.drug_id})`, px - 4, py + 44);
});
}
function renderDealerSpots() {
dealerSpots.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#e67e22";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name} (${s.drug_id}) [Pool]`, px - 4, py + 44);
});
}
function renderClothingShops() {
clothingShops.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#e84393";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name}`, px - 4, py + 44);
});
}
function renderInsuranceOffices() {
insuranceOffices.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#0984e3";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name}`, px - 4, py + 44);
});
}
function renderPlateOffices() {
plateOffices.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#636e72";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name}`, px - 4, py + 44);
});
}
function renderTrailerShops() {
trailerShops.forEach(s => {
const px = s.x * tileSize - editorCam.x;
const py = s.y * tileSize - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillStyle = "#8e6a3d";
ctx.fillRect(px, py, tileSize, tileSize);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, tileSize, tileSize);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(`#${s.id} ${s.name}`, px - 4, py + 44);
});
}
function renderTerritoryZones() {
territoryZones.forEach(z => {
const cx = z.x * tileSize - editorCam.x + tileSize / 2;
const cy = z.y * tileSize - editorCam.y + tileSize / 2;
const radius = 150;
if (cx < -radius || cy < -radius || cx > canvas.width + radius || cy > canvas.height + radius) return;
ctx.strokeStyle = "#9b59b6";
ctx.lineWidth = 2;
ctx.setLineDash([8, 6]);
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#9b59b6";
ctx.beginPath();
ctx.arc(cx, cy, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText("#" + z.id + " " + z.name, cx - 4, cy - radius - 4);
});
}
// -------------------------------------------------------------
// Render (NEU: nur sichtbarer Ausschnitt, wichtig bei großen Maps)
// -------------------------------------------------------------
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Nur die Tiles zeichnen, die aktuell im sichtbaren Bereich liegen
const startCol = Math.max(0, Math.floor(editorCam.x / tileSize));
const endCol = Math.min(cols, startCol + Math.ceil(canvas.width / tileSize) + 1);
const startRow = Math.max(0, Math.floor(editorCam.y / tileSize));
const endRow = Math.min(rows, startRow + Math.ceil(canvas.height / tileSize) + 1);
for (let y = startRow; y < endRow; y++) {
for (let x = startCol; x < endCol; x++) {
const id = tiles[y][x];
const tile = tileConfig[id];
const px = x * tileSize - editorCam.x;
const py = y * tileSize - editorCam.y;
const img = tileImageCache[id];
const rot = (tileRot[y] && tileRot[y][x]) || 0;
if (img && img.complete && img.naturalWidth > 0) {
if (rot !== 0) {
ctx.save();
ctx.translate(px + tileSize / 2, py + tileSize / 2);
ctx.rotate(rot * Math.PI / 180);
ctx.drawImage(img, -tileSize / 2, -tileSize / 2, tileSize, tileSize);
ctx.restore();
} else {
ctx.drawImage(img, px, py, tileSize, tileSize);
}
} else {
ctx.fillStyle = tile ? tile.color : "#000";
ctx.fillRect(px, py, tileSize, tileSize);
}
// kleiner Pfeil zeigt die aktuelle Drehung an (nur wenn gedreht)
if (rot !== 0) {
ctx.save();
ctx.translate(px + tileSize / 2, py + tileSize / 2);
ctx.rotate(rot * Math.PI / 180);
ctx.fillStyle = "rgba(255,255,0,0.8)";
ctx.beginPath();
ctx.moveTo(0, -tileSize / 2 + 3);
ctx.lineTo(-3, -tileSize / 2 + 8);
ctx.lineTo(3, -tileSize / 2 + 8);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// PvP-sichere Zone: kleines Schild-Symbol in der Ecke
if (tile && tile.pvpSafe) {
ctx.font = "10px Arial";
ctx.textAlign = "left";
ctx.fillText("🛡️", px + 1, py + 11);
}
ctx.strokeStyle = "#333";
ctx.strokeRect(px, py, tileSize, tileSize);
}
}
// Spawn
ctx.fillStyle = "yellow";
ctx.fillRect(spawn.x - editorCam.x, spawn.y - editorCam.y, tileSize, tileSize);
// Doors
ctx.fillStyle = "orange";
doors.forEach(d => {
const px = d.x - editorCam.x;
const py = d.y - editorCam.y;
if (px < -tileSize || py < -tileSize || px > canvas.width || py > canvas.height) return;
ctx.fillRect(px, py, tileSize, tileSize);
});
// Objects
if (layerVisibility.object) {
objects.forEach(o => {
const cfg = objectConfig[o.type];
if (!cfg) return;
const px = o.x - editorCam.x;
const py = o.y - (cfg.height - tileSize) - editorCam.y;
if (px < -cfg.width || py < -cfg.height || px > canvas.width || py > canvas.height) return;
const rot = o.rot || 0;
ctx.save();
if (rot !== 0) {
ctx.translate(px + cfg.width / 2, py + cfg.height / 2);
ctx.rotate(rot * Math.PI / 180);
ctx.translate(-cfg.width / 2, -cfg.height / 2);
} else {
ctx.translate(px, py);
}
ctx.fillStyle = cfg.color;
ctx.fillRect(0, 0, cfg.width, cfg.height);
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, cfg.width, cfg.height);
// Einbahn-Sperre: roter Balken auf der gesperrten Seite (dreht sich mit)
if (o.oneWaySide) {
ctx.fillStyle = "rgba(255,0,0,0.8)";
const barThickness = 4;
if (o.oneWaySide === "top") ctx.fillRect(0, 0, cfg.width, barThickness);
if (o.oneWaySide === "bottom") ctx.fillRect(0, cfg.height - barThickness, cfg.width, barThickness);
if (o.oneWaySide === "left") ctx.fillRect(0, 0, barThickness, cfg.height);
if (o.oneWaySide === "right") ctx.fillRect(cfg.width - barThickness, 0, barThickness, cfg.height);
}
// Kleiner Pfeil zeigt die Drehung an (nur wenn tatsächlich gedreht)
if (rot !== 0) {
ctx.fillStyle = "rgba(255,255,0,0.9)";
ctx.beginPath();
ctx.moveTo(cfg.width / 2, 4);
ctx.lineTo(cfg.width / 2 - 4, 11);
ctx.lineTo(cfg.width / 2 + 4, 11);
ctx.closePath();
ctx.fill();
}
ctx.restore();
});
}
// Shops & ATMs & Garagen
if (layerVisibility.shop) renderShops();
if (layerVisibility.atm) renderATMs();
if (layerVisibility.garage) renderGarages();
if (layerVisibility.jobcenter) renderJobcenters();
if (layerVisibility.gasstation) renderGasStations();
if (layerVisibility.repairshop) renderRepairShops();
if (layerVisibility.house) renderHouses();
if (layerVisibility.jobpoint) renderJobPoints();
if (layerVisibility.taxistand) renderTaxiStands();
if (layerVisibility.hospital) renderHospitals();
if (layerVisibility.prison) renderPrisons();
if (layerVisibility.zone) renderTerritoryZones();
if (layerVisibility.impound) renderImpoundLots();
if (layerVisibility.firestation) renderFireStations();
if (layerVisibility.drug_harvest) renderHarvestSpots();
if (layerVisibility.drug_process) renderProcessSpots();
if (layerVisibility.drug_dealer) renderDealerSpots();
if (layerVisibility.clothingshop) renderClothingShops();
if (layerVisibility.insuranceoffice) renderInsuranceOffices();
if (layerVisibility.plateoffice) renderPlateOffices();
if (layerVisibility.trailershop) renderTrailerShops();
}
panLoop();
updateCamLabel();