Files
2026-08-22 08:40:29 +02:00

5322 lines
189 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const isTouchDevice = ("ontouchstart" in window) || navigator.maxTouchPoints > 0;
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
// -------------------------------------------------------------
// LADEBILDSCHIRM: bleibt sichtbar, bis Verbindung + Login + alle
// Grunddaten (Autos, Objekte, Items, Kleidung, Radio, Tiles) da sind
// -------------------------------------------------------------
const LOADING_STEPS = ["auth", "playerState", "carConfigs", "objectConfig", "items", "clothingCatalog", "radioStations", "tileConfig"];
const loadingStepsDone = new Set();
let playerStateLoaded = false; // wird nur beim allerersten vollständigen state-Update gesetzt
function markLoadingStepDone(step, statusText) {
loadingStepsDone.add(step);
updateLoadingProgress(statusText);
if (loadingStepsDone.size >= LOADING_STEPS.length) {
hideLoadingScreen();
}
}
function updateLoadingProgress(statusText) {
const bar = document.getElementById("loadingBarInner");
const text = document.getElementById("loadingStatusText");
if (bar) bar.style.width = Math.round((loadingStepsDone.size / LOADING_STEPS.length) * 100) + "%";
if (text && statusText) text.textContent = statusText;
}
function showLoadingScreen() {
const el = document.getElementById("loadingScreen");
if (!el) return;
el.classList.remove("fading");
el.style.display = "flex";
updateLoadingProgress("Verbindung wird hergestellt...");
}
function hideLoadingScreen() {
const el = document.getElementById("loadingScreen");
if (!el) return;
el.classList.add("fading");
setTimeout(() => { el.style.display = "none"; }, 400);
}
// -------------------------------------------------------------
// CANVAS-GRÖSSE: dynamisch, kein linker Rand, rechts Platz für die
// UI-Fenster (Status/Inventar/Shop/ATM/Garage) lassen
// -------------------------------------------------------------
const TOPMENU_HEIGHT = 50; // muss zu #topMenu in game.html passen
const SIDEBAR_WIDTH = 260; // Platz rechts für die 220px breiten Fenster + Puffer
const CHAT_HEIGHT = 180; // Platz unten für den Chat
function resizeCanvas() {
// Auf Touch-Geräten liegt der Chat unter/über der Steuerung und die
// Seitenfenster werden nacheinander statt permanent nebeneinander genutzt -
// deswegen wird dort kein fester Platz mehr reserviert, die Karte nutzt den vollen Bildschirm
const sidebarReserve = isTouchDevice ? 0 : SIDEBAR_WIDTH;
const chatReserve = isTouchDevice ? 0 : CHAT_HEIGHT;
canvas.width = Math.max(320, window.innerWidth - sidebarReserve);
canvas.height = Math.max(240, window.innerHeight - TOPMENU_HEIGHT - chatReserve);
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// -------------------------------------------------------------
// CHAT
// -------------------------------------------------------------
const chatMessagesEl = document.getElementById("chatMessages");
const chatInput = document.getElementById("chatInput");
const chatSendBtn = document.getElementById("chatSendBtn");
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function addChatMessage(text, kind, from) {
const line = document.createElement("div");
line.className = "chat-line " + (kind === "system" ? "system" : kind === "radio" ? "radio" : "player");
if (kind === "system" || kind === "radio") {
line.innerHTML = escapeHtml(text);
} else {
line.innerHTML = `<span class="name">${escapeHtml(from || "???")}:</span> ${escapeHtml(text)}`;
}
chatMessagesEl.appendChild(line);
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
// Chat-Verlauf begrenzen, damit das DOM nicht unbegrenzt wächst
while (chatMessagesEl.children.length > 200) {
chatMessagesEl.removeChild(chatMessagesEl.firstChild);
}
}
let debugMode = false;
function sendChatMessage() {
const text = chatInput.value.trim();
if (!text) return;
chatInput.value = "";
if (text.startsWith("/")) {
handleChatCommand(text);
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: "chat_send", text }));
}
function handleChatCommand(text) {
const raw = text.slice(1).trim();
const cmd = raw.toLowerCase();
if (cmd === "map") {
openWorldMap();
return;
}
if (cmd === "navi cancel") {
naviTarget = null;
updateNaviDisplay();
addChatMessage("Navigation abgebrochen.", "system");
return;
}
if (cmd === "help") {
const lines = [
"📖 Verfügbare Befehle:",
"/commands - alle Befehle als klickbares Fenster mit Eingabefeldern öffnen",
"/help - diese Liste anzeigen",
"️ Bei allen Befehlen, die einen Spieler-Namen erwarten, kannst du stattdessen auch die Spieler-ID eingeben (z.B. /cuff 42 statt /cuff Name).",
"/map - Weltkarte mit allen Orten öffnen",
"J (Taste) - Job-Menü öffnen (Taxi/Polizei/Sanitäter/Abschlepper)",
"V (Taste) - Mikrofon stummschalten/aktivieren (Sprachchat)",
"🎮 Controller wird automatisch erkannt - linker Stick/D-Pad = Bewegen, A/Kreuz = Interagieren, X/Viereck = Job-Menü, Y/Dreieck = Handy",
"/navi cancel - aktive Navigation abbrechen",
"/debug - Debug-Overlay (Koordinaten) ein/aus",
"/key Name - Autoschlüssel geben (im eigenen Auto)",
"/key remove Name - Autoschlüssel entziehen",
"/housekey Name - Hausschlüssel geben (im/am eigenen Haus)",
"/housekey remove Name - Hausschlüssel entziehen",
"/rob - Raubüberfall (in Shop-/Bank-Nähe)",
"/hotwire - Auto kurzschließen (neben fremdem Auto)",
"/arrest Name - Spieler verhaften (nur Polizei-Job)",
"/pvp - zeigt an, ob du gerade in einer Sicherheitszone bist (PvP ist überall sonst automatisch aktiv)",
"/attack - mit erster Waffe im Inventar angreifen (nur bei PvP an, Ziel in Reichweite)",
"/gang create Name Tag - Bande gründen (5000$)",
"/gang invite Name / accept Bandenname / kick Name / leave",
"/gang deposit/withdraw Betrag - Bandenkasse verwalten",
"/gang info - Bandeninfo anzeigen",
"/job invite Name - in geschützten Job aufnehmen (nur wenn du selbst diesen Job hast)",
"/tow hook - freistehendes Fahrzeug anhängen (nur Abschlepper-Job, im Abschleppwagen)",
"/tow dropoff - angehängtes Fahrzeug am Abschlepphof abliefern (+50$)",
"/tow release - Fahrzeug loslassen ohne abzuliefern",
"/shop buy / unclaim / info - Shop kaufen/freigeben/Besitzer anzeigen (am Shop stehend)",
"/station buy / unclaim / info - Tankstelle kaufen/freigeben/Besitzer anzeigen (an der Tankstelle stehend)",
"/car insure / uninsure - eigenes Auto (an)versichern (kleine periodische Gebühr, Rabatt bei Totalschaden-Reparatur)",
"/tune - Tuning-Menü öffnen (an der Werkstatt, im eigenen Auto): Motor/Beschleunigung/Bremsen aufwerten, Lackierung",
"/trailer buy - Anhänger kaufen (steht neben dir)",
"/trailer hitch - eigenen Anhänger in der Nähe ankuppeln (mit jedem Fahrzeug)",
"K (Taste) - Anhänger an-/abkuppeln (automatisch je nach Zustand)",
"/trailer unhitch - angekuppelten Anhänger lösen",
"/trailer load - dein Auto (in dem du sitzt) auf einen nahen Anhänger laden",
"/trailer unload - aufgeladenes Auto wieder vom Anhänger holen",
"/plate TEXT - Nummernschild für dein Auto setzen (kostet etwas, 2-10 Zeichen)",
"/house setrent Betrag - dein eigenes Haus zur Miete anbieten (0 zum Zurückziehen)",
"/house rent - ein angebotenes Haus mieten (musst davor stehen)",
"/house evict - Mieter deines Hauses kündigen (als Besitzer)",
"/house moveout - selbst aus einem gemieteten Haus ausziehen",
"/bounty Name Betrag - Kopfgeld auf einen Spieler aussetzen (nur bei echtem PvP-Kill auszahlbar)",
"/bounty info Name - aktuelles Kopfgeld auf jemanden anzeigen",
"/cuff Name - Handschellen anlegen (nur Polizei, Person muss gesucht sein)",
"/uncuff Name - Handschellen abnehmen",
"/putin Name - gefesselte Person in dein Fahrzeug setzen",
"/takeout Name - gefesselte Person aus dem Fahrzeug holen",
"/jaildropoff Name - gefesselte Person am Gefängnis abliefern (muss bei dir im Auto sitzen)",
"/funk Nachricht - Funkspruch an alle mit demselben Job (nur Polizei/Sanitäter/Feuerwehr)",
"P (Taste) / /phone - Handy öffnen (Kontakte, SMS)",
"O (Taste) / /tablet - Einsatz-Tablet öffnen (nur Polizei/Sanitäter/Feuerwehr): Personensuche, Fahndungsliste, Kennzeichen-Abfrage",
"/ticket - Support-Tickets öffnen (eigene Anliegen ans Team melden)",
"/controller - Controller-Einstellungen öffnen (Ein/Aus, Tasten neu zuordnen)",
"/börse - Börse öffnen (Aktien kaufen/verkaufen, Portfolio ansehen)",
"/verkaufen - gestohlenes Auto beim Hehler verkaufen (musst dort stehen + im Auto sitzen) - Aufpreis, aber Entdeckungsrisiko!",
"/leitstelle - Leitstellen-Übersicht öffnen (nur Polizei/Rettungsdienst/Feuerwehr): alle Einheiten im Dienst + FMS-Status, Einsätze verteilen",
"/phone add Name - Kontaktanfrage senden",
"/phone accept Name - Kontaktanfrage annehmen",
"/phone remove Name - Kontakt entfernen",
"/sms Name Nachricht - SMS senden",
"/call Name - anrufen (nur wenn online, funktioniert über jede Entfernung)",
"/fire extinguish - nächstes Feuer löschen (nur Feuerwehr-Job)",
"Drogen: E an 🌿 Anbaustelle (ernten), ⚗️ Labor (verarbeiten), 💰 Verkaufsort (verkaufen, wechselt danach Ort+Preis)",
"/gate claim - Tor beanspruchen (wird Besitzer)",
"/gate unclaim - Tor wieder freigeben",
"/gate info - Besitzer/Schlüssel-Inhaber anzeigen",
"/gatekey Name - Schlüssel vergeben (nur Besitzer, am Tor stehen)",
"/gatekey remove Name - Schlüssel entziehen"
];
if (player.isAdmin) {
lines.push("/admin - Admin-Menü öffnen");
lines.push("/restart [Sekunden] - Server-Neustart ankündigen (Standard 60s)");
lines.push("/restart cancel - laufenden Neustart abbrechen");
lines.push("/admin duty - Dienstmodus (jedes Auto fahren) an/aus");
lines.push("/gate setjob Jobname - Tor auf einen Job beschränken (am Tor stehen, 'none' zum Entfernen)");
}
lines.forEach(line => addChatMessage(line, "system"));
return;
}
if (cmd === "admin") {
if (!player.isAdmin) {
addChatMessage("Kein Admin-Zugriff.", "system");
return;
}
openAdminMenu();
return;
}
if (cmd === "debug") {
debugMode = !debugMode;
document.getElementById("debugBox").style.display = debugMode ? "block" : "none";
addChatMessage(`Debug-Fenster ${debugMode ? "eingeschaltet" : "ausgeschaltet"}.`, "system");
return;
}
if (cmd === "commands") {
openCommandsWindow();
return;
}
if (cmd.startsWith("key remove ")) {
const username = raw.slice(11).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "car_revoke_key", username }));
return;
}
if (cmd.startsWith("key ")) {
const username = raw.slice(4).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "car_give_key", username }));
return;
}
if (cmd.startsWith("housekey remove ")) {
const username = raw.slice(16).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "house_revoke_key", username }));
return;
}
if (cmd.startsWith("housekey ")) {
const username = raw.slice(9).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "house_give_key", username }));
return;
}
if (cmd === "rob") {
ws.send(JSON.stringify({ type: "commit_crime", crimeType: "robbery" }));
return;
}
if (cmd === "hotwire") {
ws.send(JSON.stringify({ type: "commit_crime", crimeType: "car_theft" }));
return;
}
if (cmd.startsWith("gang ") || cmd === "gang") {
const parts = raw.split(" ");
const sub = parts[1] ? parts[1].toLowerCase() : "";
const rest = parts.slice(2);
if (sub === "create") {
if (rest.length < 2) {
addChatMessage("Nutzung: /gang create Name Tag (z.B. /gang create Street Kings SK)", "system");
return;
}
const tag = rest[rest.length - 1];
const name = rest.slice(0, -1).join(" ");
ws.send(JSON.stringify({ type: "gang_create", name, tag }));
return;
}
if (sub === "invite") {
const username = rest.join(" ");
if (!username) return;
ws.send(JSON.stringify({ type: "gang_invite", username }));
return;
}
if (sub === "accept") {
const gangName = rest.join(" ");
if (!gangName) return;
ws.send(JSON.stringify({ type: "gang_accept", gangName }));
return;
}
if (sub === "kick") {
const username = rest.join(" ");
if (!username) return;
ws.send(JSON.stringify({ type: "gang_kick", username }));
return;
}
if (sub === "leave") {
ws.send(JSON.stringify({ type: "gang_leave" }));
return;
}
if (sub === "deposit") {
const amount = Number(rest[0]);
if (!amount || amount <= 0) return;
ws.send(JSON.stringify({ type: "gang_deposit", amount }));
return;
}
if (sub === "withdraw") {
const amount = Number(rest[0]);
if (!amount || amount <= 0) return;
ws.send(JSON.stringify({ type: "gang_withdraw", amount }));
return;
}
if (sub === "info") {
ws.send(JSON.stringify({ type: "gang_info" }));
return;
}
addChatMessage("Unbekannter /gang Befehl. Siehe /help", "system");
return;
}
if (cmd === "gate claim") {
ws.send(JSON.stringify({ type: "gate_claim" }));
return;
}
if (cmd === "gate unclaim") {
ws.send(JSON.stringify({ type: "gate_unclaim" }));
return;
}
if (cmd === "gate info") {
ws.send(JSON.stringify({ type: "gate_info" }));
return;
}
if (cmd.startsWith("gate setjob ")) {
const jobName = raw.slice(12).trim();
if (!jobName) return;
ws.send(JSON.stringify({ type: "gate_set_job", jobName }));
return;
}
if (cmd === "tow hook") {
ws.send(JSON.stringify({ type: "tow_hook" }));
return;
}
if (cmd === "tow dropoff") {
ws.send(JSON.stringify({ type: "tow_dropoff" }));
return;
}
if (cmd === "tow release") {
ws.send(JSON.stringify({ type: "tow_release" }));
return;
}
if (cmd === "shop buy") {
ws.send(JSON.stringify({ type: "shop_claim" }));
return;
}
if (cmd === "shop unclaim") {
ws.send(JSON.stringify({ type: "shop_unclaim" }));
return;
}
if (cmd === "shop info") {
ws.send(JSON.stringify({ type: "shop_owner_info" }));
return;
}
if (cmd === "station buy") {
ws.send(JSON.stringify({ type: "station_claim" }));
return;
}
if (cmd === "station unclaim") {
ws.send(JSON.stringify({ type: "station_unclaim" }));
return;
}
if (cmd === "station info") {
ws.send(JSON.stringify({ type: "station_owner_info" }));
return;
}
if (cmd === "car insure") {
ws.send(JSON.stringify({ type: "car_insure" }));
return;
}
if (cmd === "car uninsure") {
ws.send(JSON.stringify({ type: "car_uninsure" }));
return;
}
if (cmd === "tune") {
ws.send(JSON.stringify({ type: "tune_menu_open" }));
return;
}
if (cmd.startsWith("bounty info ")) {
const username = raw.slice(12).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "bounty_info", username }));
return;
}
if (cmd.startsWith("funk ")) {
const text = raw.slice(5).trim();
if (!text) return;
ws.send(JSON.stringify({ type: "radio_message", text }));
return;
}
if (cmd === "phone") {
openPhone();
return;
}
if (cmd === "tablet") {
openTablet();
return;
}
if (cmd === "ticket" || cmd === "tickets") {
openTicketBox();
return;
}
if (cmd === "controller" || cmd === "gamepad") {
openGamepadSettings();
return;
}
if (cmd === "börse" || cmd === "boerse" || cmd === "stocks") {
openStockBox();
return;
}
if (cmd === "verkaufen" || cmd === "hehler") {
ws.send(JSON.stringify({ type: "sell_stolen_car" }));
return;
}
if (cmd === "leitstelle") {
openLeitstelle();
return;
}
if (cmd.startsWith("phone add ")) {
const username = raw.slice(10).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "phone_add_contact", username }));
return;
}
if (cmd.startsWith("phone accept ")) {
const username = raw.slice(13).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "phone_accept_contact", username }));
return;
}
if (cmd.startsWith("phone remove ")) {
const username = raw.slice(13).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "phone_remove_contact", username }));
return;
}
if (cmd.startsWith("sms ")) {
const parts = raw.slice(4).trim().split(" ");
const username = parts.shift();
const text = parts.join(" ").trim();
if (!username || !text) {
addChatMessage("Nutzung: /sms Name Nachricht", "system");
return;
}
ws.send(JSON.stringify({ type: "phone_send_sms", username, text }));
return;
}
if (cmd.startsWith("call ")) {
const username = raw.slice(5).trim();
if (!username) return;
initiateCall(username);
return;
}
if (cmd.startsWith("cuff ")) {
const username = raw.slice(5).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "police_cuff", username }));
return;
}
if (cmd.startsWith("uncuff ")) {
const username = raw.slice(7).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "police_uncuff", username }));
return;
}
if (cmd.startsWith("putin ")) {
const username = raw.slice(6).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "police_put_in_car", username }));
return;
}
if (cmd.startsWith("takeout ")) {
const username = raw.slice(8).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "police_take_out_of_car", username }));
return;
}
if (cmd.startsWith("jaildropoff ")) {
const username = raw.slice(12).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "police_jail_dropoff", username }));
return;
}
if (cmd.startsWith("bounty ")) {
const parts = raw.slice(7).trim().split(" ");
const amount = Number(parts.pop());
const username = parts.join(" ").trim();
if (!username || !amount) {
addChatMessage("Nutzung: /bounty Name Betrag", "system");
return;
}
ws.send(JSON.stringify({ type: "bounty_place", username, amount }));
return;
}
if (cmd === "trailer buy") {
ws.send(JSON.stringify({ type: "trailer_buy" }));
return;
}
if (cmd === "trailer hitch") {
ws.send(JSON.stringify({ type: "trailer_hitch" }));
return;
}
if (cmd === "trailer unhitch") {
ws.send(JSON.stringify({ type: "trailer_unhitch" }));
return;
}
if (cmd === "trailer load") {
ws.send(JSON.stringify({ type: "trailer_load_car" }));
return;
}
if (cmd === "trailer unload") {
ws.send(JSON.stringify({ type: "trailer_unload_car" }));
return;
}
if (cmd.startsWith("plate ")) {
const plate = raw.slice(6).trim();
if (!plate) return;
ws.send(JSON.stringify({ type: "car_set_plate", plate }));
return;
}
if (cmd.startsWith("house setrent ")) {
const price = Number(raw.slice(14).trim());
if (isNaN(price) || price < 0) {
addChatMessage("Nutzung: /house setrent Betrag (0 zum Zurückziehen)", "system");
return;
}
ws.send(JSON.stringify({ type: "house_set_rent", rentPrice: price }));
return;
}
if (cmd === "house unrent") {
ws.send(JSON.stringify({ type: "house_set_rent", rentPrice: 0 }));
return;
}
if (cmd === "house rent") {
ws.send(JSON.stringify({ type: "house_rent" }));
return;
}
if (cmd === "house evict" || cmd === "house moveout") {
ws.send(JSON.stringify({ type: "house_end_rental" }));
return;
}
if (cmd === "fire extinguish") {
if (fires.length === 0) {
addChatMessage("Kein Feuer in der Nähe.", "system");
return;
}
// nächstes Feuer zur eigenen Position finden
let closest = null;
let closestDist = Infinity;
fires.forEach(f => {
const d = Math.hypot(f.x - player.x, f.y - player.y);
if (d < closestDist) { closestDist = d; closest = f; }
});
if (closest) ws.send(JSON.stringify({ type: "fire_extinguish", fireId: closest.id }));
return;
}
if (cmd.startsWith("gatekey remove ")) {
const username = raw.slice(15).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "gate_revoke_key", username }));
return;
}
if (cmd.startsWith("gatekey ")) {
const username = raw.slice(8).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "gate_give_key", username }));
return;
}
if (cmd.startsWith("job invite ")) {
const username = raw.slice(11).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "job_invite", username }));
return;
}
if (cmd === "admin duty") {
if (!player.isAdmin) {
addChatMessage("Kein Admin-Zugriff.", "system");
return;
}
ws.send(JSON.stringify({ type: "admin_toggle_duty" }));
return;
}
if (cmd.startsWith("arrest ")) {
const username = raw.slice(7).trim();
if (!username) return;
ws.send(JSON.stringify({ type: "arrest_player", username }));
return;
}
if (cmd === "restart cancel") {
if (!player.isAdmin) {
addChatMessage("Kein Admin-Zugriff.", "system");
return;
}
ws.send(JSON.stringify({ type: "admin_restart_server", cancel: true }));
return;
}
if (cmd.startsWith("restart")) {
if (!player.isAdmin) {
addChatMessage("Kein Admin-Zugriff.", "system");
return;
}
const parts = raw.split(" ");
const seconds = parts[1] ? Number(parts[1]) : 60;
ws.send(JSON.stringify({ type: "admin_restart_server", seconds }));
return;
}
if (cmd === "pvp") {
ws.send(JSON.stringify({ type: "toggle_pvp" }));
return;
}
if (cmd === "attack") {
const weapon = (player.inventory || []).find(i => {
const info = itemInfoCache[i.id];
return info && (info.type === "weapon_melee" || info.type === "weapon_ranged");
});
if (!weapon) {
addChatMessage("Du hast keine Waffe im Inventar.", "system");
return;
}
ws.send(JSON.stringify({ type: "attack", weaponId: weapon.id }));
return;
}
addChatMessage(`Unbekannter Befehl: ${text} (probier /help)`, "system");
}
function updateDebugBox() {
if (!debugMode) return;
const tileX = Math.floor(player.x / 32);
const tileY = Math.floor(player.y / 32);
const cam = getCamera();
document.getElementById("dbgWorld").textContent = player.world;
document.getElementById("dbgPos").textContent = `${Math.round(player.x)}, ${Math.round(player.y)}`;
document.getElementById("dbgTile").textContent = `${tileX}, ${tileY}`;
document.getElementById("dbgCam").textContent = `${cam.x}, ${cam.y}`;
document.getElementById("dbgFacing").textContent = drivingCarId ? "im Auto" : "zu Fuß";
}
chatSendBtn.addEventListener("click", sendChatMessage);
chatInput.addEventListener("keydown", e => {
if (e.key === "Enter") {
e.preventDefault();
sendChatMessage();
}
e.stopPropagation(); // verhindert, dass Tippen im Chat die Spielsteuerung (WASD) auslöst
});
const loginBox = document.getElementById("loginBox");
const registerBox = document.getElementById("registerBox");
const loginUser = document.getElementById("loginUser");
const loginPass = document.getElementById("loginPass");
const loginBtn = document.getElementById("loginBtn");
const regUser = document.getElementById("regUser");
const regPass = document.getElementById("regPass");
const regBtn = document.getElementById("regBtn");
const switchToRegister = document.getElementById("switchToRegister");
const switchToLogin = document.getElementById("switchToLogin");
// Falls bereits ein Token aus der Startseite (index.html) im Browser liegt,
// Login-Formular gar nicht erst anzeigen (Aufblitzen vermeiden)
if (localStorage.getItem("token")) {
loginBox.style.display = "none";
}
const shopWindow = document.getElementById("shopWindow");
const shopList = document.getElementById("shopList");
let ws = null;
let maps = {};
let tileConfig = {};
let lastServerUpdate = 0;
let player = {
id: null,
x: 100,
y: 100,
world: "stadt"
};
player.health = 100;
player.hunger = 100;
player.thirst = 100;
player.inventory = [];
player.money = 1000;
let loggedIn = false;
let otherPlayers = [];
let keys = {};
// -------------------------------------------------------------
// GAMEPAD-UNTERSTÜTZUNG
// -------------------------------------------------------------
// Bewegung/Fahren: linker Stick + D-Pad speisen direkt keys["w"/"a"/"s"/"d"],
// dieselben Variablen wie die Tastatur - die bestehende Bewegungs-/Fahrlogik
// merkt also gar nicht, ob Tastatur oder Controller benutzt wird.
//
// Aktions-Tasten (E, J, P, V, usw.): simulieren echte Tastatur-Events, damit
// alle bereits existierenden keydown-Handler automatisch mitfunktionieren,
// ohne die Logik doppelt pflegen zu müssen.
const GAMEPAD_STICK_DEADZONE = 0.3;
const GAMEPAD_DEFAULT_BUTTON_KEY_MAP = {
0: "e", // A / Kreuz - interagieren, einsteigen
1: "Escape", // B / Kreis - Fenster schließen
2: "j", // X / Viereck - Job-Menü
3: "p", // Y / Dreieck - Handy
4: "1", // linke Schulter - Blinker links
5: "2", // rechte Schulter - Blinker rechts
6: "q", // linker Trigger - Tanken/Reparieren/Tor
7: "5", // rechter Trigger - Blaulicht
8: "v", // Select/Back - Mikro stumm
9: "m", // Start - (falls belegt, sonst ignoriert)
12: "ArrowUp", // D-Pad hoch (zusätzlich zum Stick, z.B. für Kamera/Scrollen im Editor)
13: "ArrowDown",
14: "ArrowLeft",
15: "ArrowRight"
};
// Beschriftungen für die Einstellungs-Oberfläche: welche Aktion liegt hinter welcher Taste
const GAMEPAD_ACTION_LABELS = {
"e": "Interagieren / Einsteigen",
"Escape": "Fenster schließen",
"j": "Job-Menü",
"p": "Handy",
"1": "Blinker links",
"2": "Blinker rechts",
"q": "Tanken / Reparieren / Tor",
"5": "Blaulicht",
"v": "Mikro stumm/an"
};
// Eigene Zuordnung aus dem Browser laden, sonst Standard nehmen
let GAMEPAD_BUTTON_KEY_MAP = { ...GAMEPAD_DEFAULT_BUTTON_KEY_MAP };
try {
const savedMap = localStorage.getItem("gamepadButtonMap");
if (savedMap) GAMEPAD_BUTTON_KEY_MAP = { ...GAMEPAD_DEFAULT_BUTTON_KEY_MAP, ...JSON.parse(savedMap) };
} catch {}
let gamepadEnabled = localStorage.getItem("gamepadEnabled") !== "false"; // standardmäßig an
let gamepadButtonState = {}; // Index -> war im letzten Frame gedrückt?
let gamepadConnected = false;
let gamepadMove = { w: false, a: false, s: false, d: false }; // eigener Status, wird jeden Poll frisch gesetzt
// -------------------------------------------------------------
// MOBILE TOUCH-STEUERUNG (virtueller Joystick + Action-Buttons)
// -------------------------------------------------------------
let touchMove = { w: false, a: false, s: false, d: false };
if (isTouchDevice) {
document.getElementById("touchControls").style.display = "block";
}
window.addEventListener("gamepadconnected", e => {
gamepadConnected = true;
addChatMessage(`🎮 Controller verbunden: ${e.gamepad.id}`, "system");
});
window.addEventListener("gamepaddisconnected", () => {
gamepadConnected = false;
gamepadButtonState = {};
gamepadMove = { w: false, a: false, s: false, d: false };
addChatMessage("🎮 Controller getrennt.", "system");
});
let gamepadRemapListenAction = null; // gesetzt, während auf einen Knopfdruck zum Neu-Zuordnen gewartet wird
function pollGamepad() {
if (!gamepadEnabled) return;
// Fallback: manche Browser feuern "gamepadconnected" nicht zuverlässig,
// wenn der Controller schon vor dem Laden der Seite verbunden war
if (!gamepadConnected) {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
if (pads[0]) {
gamepadConnected = true;
addChatMessage(`🎮 Controller erkannt: ${pads[0].id}`, "system");
}
}
if (!gamepadConnected) return;
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = pads[0];
if (!gp) return;
// Neu-Zuordnen-Modus: auf den nächsten gedrückten Knopf warten und zuordnen,
// statt normal weiterzuspielen
if (gamepadRemapListenAction) {
for (let i = 0; i < gp.buttons.length; i++) {
if (gp.buttons[i].pressed || gp.buttons[i].value > 0.5) {
GAMEPAD_BUTTON_KEY_MAP[i] = gamepadRemapListenAction;
localStorage.setItem("gamepadButtonMap", JSON.stringify(GAMEPAD_BUTTON_KEY_MAP));
gamepadRemapListenAction = null;
renderGamepadSettings();
return;
}
}
return;
}
// Linker Stick + D-Pad -> eigenes Bewegungs-Status-Objekt, jeden Poll frisch berechnet
// (nicht direkt in keys[] schreiben, sonst kann der Zustand bei Loslassen "hängen bleiben")
const stickX = gp.axes[0] || 0;
const stickY = gp.axes[1] || 0;
const dpadUp = !!(gp.buttons[12] && gp.buttons[12].pressed);
const dpadDown = !!(gp.buttons[13] && gp.buttons[13].pressed);
const dpadLeft = !!(gp.buttons[14] && gp.buttons[14].pressed);
const dpadRight = !!(gp.buttons[15] && gp.buttons[15].pressed);
gamepadMove.w = stickY < -GAMEPAD_STICK_DEADZONE || dpadUp;
gamepadMove.s = stickY > GAMEPAD_STICK_DEADZONE || dpadDown;
gamepadMove.a = stickX < -GAMEPAD_STICK_DEADZONE || dpadLeft;
gamepadMove.d = stickX > GAMEPAD_STICK_DEADZONE || dpadRight;
// Buttons: einmaliges Tastatur-Event simulieren beim Drücken, keys[] beim Loslassen zurücksetzen
gp.buttons.forEach((btn, i) => {
const mappedKey = GAMEPAD_BUTTON_KEY_MAP[i];
if (!mappedKey) return;
const isPressed = btn.pressed || btn.value > 0.5;
const wasPressed = !!gamepadButtonState[i];
if (isPressed && !wasPressed) {
document.dispatchEvent(new KeyboardEvent("keydown", { key: mappedKey }));
}
if (!isPressed && wasPressed) {
document.dispatchEvent(new KeyboardEvent("keyup", { key: mappedKey }));
}
gamepadButtonState[i] = isPressed;
});
}
// -------------------------------------------------------------
// CONTROLLER-EINSTELLUNGEN: Ein/Aus + eigene Tastenzuordnung
// -------------------------------------------------------------
const GAMEPAD_BUTTON_NAMES = {
0: "A / Kreuz", 1: "B / Kreis", 2: "X / Viereck", 3: "Y / Dreieck",
4: "Linke Schulter (LB)", 5: "Rechte Schulter (RB)",
6: "Linker Trigger (LT)", 7: "Rechter Trigger (RT)",
8: "Select / Back", 9: "Start",
12: "D-Pad hoch", 13: "D-Pad runter", 14: "D-Pad links", 15: "D-Pad rechts"
};
function openGamepadSettings() {
document.getElementById("gamepadEnabledCheckbox").checked = gamepadEnabled;
renderGamepadSettings();
document.getElementById("gamepadSettingsBox").style.display = "flex";
}
function closeGamepadSettings() {
gamepadRemapListenAction = null;
document.getElementById("gamepadSettingsBox").style.display = "none";
}
function toggleGamepadEnabled() {
gamepadEnabled = document.getElementById("gamepadEnabledCheckbox").checked;
localStorage.setItem("gamepadEnabled", gamepadEnabled ? "true" : "false");
if (!gamepadEnabled) {
gamepadMove.w = gamepadMove.a = gamepadMove.s = gamepadMove.d = false;
}
addChatMessage(gamepadEnabled ? "🎮 Controller aktiviert." : "🎮 Controller deaktiviert.", "system");
}
function renderGamepadSettings() {
const list = document.getElementById("gamepadActionList");
list.innerHTML = "";
for (const [action, label] of Object.entries(GAMEPAD_ACTION_LABELS)) {
const currentIndex = Object.entries(GAMEPAD_BUTTON_KEY_MAP).find(([, key]) => key === action)?.[0];
const currentName = currentIndex !== undefined ? (GAMEPAD_BUTTON_NAMES[currentIndex] || `Taste ${currentIndex}`) : "- keine -";
const isListening = gamepadRemapListenAction === action;
const row = document.createElement("div");
row.className = "gamepad-action-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(label)}</span>
<span class="gamepad-current-btn">${escapeHtmlAdmin(currentName)}</span>
<button class="${isListening ? "listening" : ""}" onclick="startGamepadRemap('${action}')">
${isListening ? "Knopf drücken..." : "Neu zuordnen"}
</button>
`;
list.appendChild(row);
}
}
function startGamepadRemap(action) {
if (!gamepadConnected) {
addChatMessage("🎮 Kein Controller verbunden - zum Neu-Zuordnen muss einer angeschlossen sein.", "system");
return;
}
gamepadRemapListenAction = action;
renderGamepadSettings();
}
function resetGamepadMapping() {
GAMEPAD_BUTTON_KEY_MAP = { ...GAMEPAD_DEFAULT_BUTTON_KEY_MAP };
localStorage.removeItem("gamepadButtonMap");
renderGamepadSettings();
addChatMessage("🎮 Tastenzuordnung auf Standard zurückgesetzt.", "system");
}
let shops = [];
let atms = [];
let garages = [];
let jobcenters = [];
let gasStations = [];
let clothingShops = [];
let trailerShops = [];
let currentShopIsOwner = false;
let highwayLinks = [];
let blackMarketSpots = [];
let insuranceOffices = [];
let plateOffices = [];
let repairShops = [];
let houses = [];
let jobPoints = [];
let taxiStands = [];
let hospitals = [];
let prisons = [];
let territoryZones = [];
let impoundLots = [];
let fireStations = [];
let harvestSpots = [];
let processSpots = [];
let dealerSpots = [];
let fires = [];
let groundDrops = [];
let activeEvents = { doubleXp: { active: false, endsAt: 0 }, discount: { active: false, endsAt: 0, percent: 0 } };
let naviTarget = null; // { x, y, world, label }
let jailedUntil = null;
let gameHour = 12;
let crimeAlerts = [];
let weather = "clear";
let rainDrops = [];
for (let i = 0; i < 120; i++) {
rainDrops.push({ x: Math.random(), y: Math.random(), speed: 8 + Math.random() * 6 });
}
// -------------------------------------------------------------
// AUTOS (NEU)
// -------------------------------------------------------------
let cars = [];
let drivingCarId = null;
let passengerCarId = null;
let carConfigs = {};
const carImageCache = {};
fetch("/api/car_configs")
.then(res => res.json())
.then(data => {
carConfigs = data.configs || {};
Object.entries(carConfigs).forEach(([model, cfg]) => {
if (cfg.image) {
const img = new Image();
img.src = cfg.image;
carImageCache[model] = img;
}
});
markLoadingStepDone("carConfigs", "Fahrzeuge geladen...");
})
.catch(() => markLoadingStepDone("carConfigs"));
let objectConfig = {};
fetch("/api/object_config")
.then(res => res.json())
.then(data => {
objectConfig = data;
markLoadingStepDone("objectConfig", "Objekte geladen...");
})
.catch(() => markLoadingStepDone("objectConfig"));
// Item-Infos (Typ, Schaden, Reichweite) für Client-seitige Waffen-Erkennung
let itemInfoCache = {};
fetch("/api/items")
.then(res => res.json())
.then(data => {
(data.items || []).forEach(i => {
itemInfoCache[i.id] = { name: i.name, type: i.type, damage: i.damage, weaponRange: i.weapon_range };
});
markLoadingStepDone("items", "Items geladen...");
})
.catch(() => markLoadingStepDone("items"));
let clothingCatalogCache = {}; // id -> { id, slot, name, color, price, image }
fetch("/api/clothing_catalog")
.then(res => res.json())
.then(data => {
(data.items || []).forEach(i => { clothingCatalogCache[i.id] = i; });
markLoadingStepDone("clothingCatalog", "Kleidung geladen...");
})
.catch(() => markLoadingStepDone("clothingCatalog"));
// -------------------------------------------------------------
// RADIO IM AUTO (NEU)
// -------------------------------------------------------------
let radioStations = [];
let currentRadioIndex = -1; // -1 = aus
const radioAudio = document.getElementById("radioAudio");
fetch("/api/radio_stations")
.then(res => res.json())
.then(data => {
radioStations = data.stations || [];
markLoadingStepDone("radioStations", "Radiosender geladen...");
})
.catch(() => markLoadingStepDone("radioStations"));
// -------------------------------------------------------------
// SPRACHCHAT (LiveKit, Nahbereichs-Sprachchat pro Spielwelt)
// -------------------------------------------------------------
let voiceRoom = null;
let voiceCurrentWorld = null;
let voiceMicEnabled = true;
let voiceConnecting = false;
const voiceAudioElements = {}; // Spieler-ID (als String) -> <audio>-Element
const VOICE_MAX_RANGE = 500; // px - ab hier komplett stumm, linear leiser werdend
async function connectVoiceChat(world) {
if (typeof LivekitClient === "undefined") {
console.warn("[Sprachchat] LiveKit-SDK nicht geladen.");
return;
}
if (voiceConnecting) return;
if (voiceRoom && voiceCurrentWorld === world) return; // schon im richtigen Raum
voiceConnecting = true;
await disconnectVoiceChat();
try {
const authToken = localStorage.getItem("token");
const res = await fetch("/api/voice/token", { headers: { "Authorization": "Bearer " + authToken } });
const data = await res.json();
if (!data.ok) {
updateVoiceDisplay("nicht eingerichtet", "#888");
voiceConnecting = false;
return;
}
const room = new LivekitClient.Room();
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind !== "audio") return;
const el = track.attach();
el.autoplay = true;
el.style.display = "none";
document.body.appendChild(el);
voiceAudioElements[participant.identity] = el;
});
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
track.detach().forEach(el => el.remove());
delete voiceAudioElements[participant.identity];
});
room.on(LivekitClient.RoomEvent.Disconnected, () => {
if (voiceRoom === room) updateVoiceDisplay("getrennt", "#888");
});
await room.connect(data.url, data.token);
await room.localParticipant.setMicrophoneEnabled(voiceMicEnabled);
voiceRoom = room;
voiceCurrentWorld = world;
updateVoiceDisplay(voiceMicEnabled ? "an" : "stumm", voiceMicEnabled ? "#6fbf73" : "#e0a02c");
} catch (err) {
console.error("[Sprachchat] Verbindung fehlgeschlagen:", err);
updateVoiceDisplay("Fehler", "#e06c6c");
}
voiceConnecting = false;
}
async function disconnectVoiceChat() {
if (voiceRoom) {
try { await voiceRoom.disconnect(); } catch {}
}
voiceRoom = null;
voiceCurrentWorld = null;
Object.values(voiceAudioElements).forEach(el => el.remove());
for (const key in voiceAudioElements) delete voiceAudioElements[key];
}
function toggleMic() {
voiceMicEnabled = !voiceMicEnabled;
if (voiceRoom) {
voiceRoom.localParticipant.setMicrophoneEnabled(voiceMicEnabled).catch(() => {});
}
updateVoiceDisplay(voiceMicEnabled ? "an" : "stumm", voiceMicEnabled ? "#6fbf73" : "#e0a02c");
}
function updateVoiceDisplay(status, color) {
const el = document.getElementById("voiceDisplay");
if (!el) return;
el.textContent = "🎤 " + status;
el.style.color = color;
}
// Lautstärke jedes Mitspielers laufend nach Entfernung anpassen (Nahbereichs-Effekt)
setInterval(() => {
if (!voiceRoom) return;
for (const [identity, audioEl] of Object.entries(voiceAudioElements)) {
const otherId = Number(identity);
const other = otherPlayers.find(p => p.id === otherId);
if (!other || other.world !== player.world) {
audioEl.volume = 0;
continue;
}
const dist = Math.hypot(other.x - player.x, other.y - player.y);
audioEl.volume = Math.max(0, Math.min(1, 1 - dist / VOICE_MAX_RANGE));
}
}, 200);
// -------------------------------------------------------------
// MOBILTELEFON: KONTAKTE, SMS, ANRUFE
// -------------------------------------------------------------
let phoneContacts = [];
let phonePendingRequests = [];
let currentSmsContact = null;
let incomingCallId = null;
let incomingCallerName = null;
let callRoom = null;
let activeCallInfo = null; // { otherUsername }
function openPhone() {
document.getElementById("phoneBox").style.display = "flex";
ws.send(JSON.stringify({ type: "phone_get_contacts" }));
}
function closePhone() {
document.getElementById("phoneBox").style.display = "none";
}
document.querySelectorAll(".phone-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".phone-tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".phone-panel").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById("phonePanel-" + btn.dataset.phoneTab).classList.add("active");
});
});
function renderPhoneContacts() {
const pendingEl = document.getElementById("pendingRequestsList");
pendingEl.innerHTML = "";
phonePendingRequests.forEach(username => {
const row = document.createElement("div");
row.className = "contact-row";
row.innerHTML = `
<span>📩 ${escapeHtmlAdmin(username)} möchte dich hinzufügen</span>
<span>
<button class="call" onclick="phoneAcceptContact('${escapeHtmlAdmin(username)}')">Annehmen</button>
</span>
`;
pendingEl.appendChild(row);
});
const listEl = document.getElementById("contactsList");
listEl.innerHTML = "";
if (phoneContacts.length === 0) {
listEl.innerHTML = `<div style="color:#666; font-size:12px; padding:8px 0;">Noch keine Kontakte.</div>`;
}
phoneContacts.forEach(c => {
const row = document.createElement("div");
row.className = "contact-row";
row.innerHTML = `
<span><span class="dot ${c.online ? "online" : "offline"}"></span>${escapeHtmlAdmin(c.username)}</span>
<span>
<button onclick="phoneOpenSmsWith('${escapeHtmlAdmin(c.username)}')">SMS</button>
<button class="call" ${c.online ? "" : "disabled"} onclick="initiateCall('${escapeHtmlAdmin(c.username)}')">Anrufen</button>
<button class="danger" onclick="phoneRemoveContact('${escapeHtmlAdmin(c.username)}')">Entfernen</button>
</span>
`;
listEl.appendChild(row);
});
const select = document.getElementById("smsContactSelect");
select.innerHTML = phoneContacts.map(c => `<option value="${escapeHtmlAdmin(c.username)}">${escapeHtmlAdmin(c.username)}</option>`).join("")
|| `<option value="">Keine Kontakte</option>`;
}
function phoneAddContact() {
const input = document.getElementById("phoneAddInput");
const username = input.value.trim();
if (!username) return;
ws.send(JSON.stringify({ type: "phone_add_contact", username }));
input.value = "";
}
function phoneAcceptContact(username) {
ws.send(JSON.stringify({ type: "phone_accept_contact", username }));
}
function phoneRemoveContact(username) {
if (!confirm(`${username} wirklich aus den Kontakten entfernen?`)) return;
ws.send(JSON.stringify({ type: "phone_remove_contact", username }));
}
function phoneOpenSmsWith(username) {
currentSmsContact = username;
document.querySelectorAll(".phone-tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".phone-panel").forEach(p => p.classList.remove("active"));
document.querySelector('.phone-tab-btn[data-phone-tab="sms"]').classList.add("active");
document.getElementById("phonePanel-sms").classList.add("active");
document.getElementById("smsContactSelect").value = username;
ws.send(JSON.stringify({ type: "phone_get_messages", username }));
}
document.getElementById("smsContactSelect").addEventListener("change", e => {
if (e.target.value) phoneOpenSmsWith(e.target.value);
});
function renderSmsThread(messages) {
const el = document.getElementById("smsThread");
el.innerHTML = "";
messages.forEach(m => {
const bubble = document.createElement("div");
bubble.className = "sms-bubble " + (m.fromMe ? "mine" : "theirs");
bubble.textContent = m.text;
el.appendChild(bubble);
});
el.scrollTop = el.scrollHeight;
}
function phoneSendSms() {
if (!currentSmsContact) {
addChatMessage("Erst einen Kontakt auswählen.", "system");
return;
}
const input = document.getElementById("phoneSmsInput");
const text = input.value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: "phone_send_sms", username: currentSmsContact, text }));
input.value = "";
}
// -------------------------------------------------------------
// ANRUFE
// -------------------------------------------------------------
function initiateCall(username) {
ws.send(JSON.stringify({ type: "phone_call_request", username }));
}
function showIncomingCall(callId, callerName) {
incomingCallId = callId;
incomingCallerName = callerName;
document.getElementById("incomingCallText").textContent = `📞 Anruf von ${callerName}...`;
document.getElementById("incomingCallBox").style.display = "block";
}
function hideIncomingCall() {
incomingCallId = null;
incomingCallerName = null;
document.getElementById("incomingCallBox").style.display = "none";
}
function acceptIncomingCall() {
if (incomingCallId === null) return;
ws.send(JSON.stringify({ type: "phone_call_accept", callId: incomingCallId }));
hideIncomingCall();
}
function declineIncomingCall() {
if (incomingCallId === null) return;
ws.send(JSON.stringify({ type: "phone_call_decline", callId: incomingCallId }));
hideIncomingCall();
}
async function connectToCallRoom(data) {
// Mikrofon im Nahbereichs-Sprachchat kurz stummschalten, damit es
// während des Anrufs nicht doppelt in beide Räume übertragen wird
if (voiceRoom) {
try { await voiceRoom.localParticipant.setMicrophoneEnabled(false); } catch {}
}
try {
const room = new LivekitClient.Room();
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
if (track.kind !== "audio") return;
const el = track.attach();
el.id = "callAudioEl";
el.autoplay = true;
document.body.appendChild(el);
});
room.on(LivekitClient.RoomEvent.Disconnected, () => {
teardownCallRoom(false);
});
await room.connect(data.url, data.token);
await room.localParticipant.setMicrophoneEnabled(true);
callRoom = room;
activeCallInfo = { otherUsername: data.otherUsername };
document.getElementById("outgoingCallBox").style.display = "none";
document.getElementById("activeCallText").textContent = `📞 Im Gespräch mit ${data.otherUsername}`;
document.getElementById("activeCallBar").style.display = "block";
} catch (err) {
console.error("[Anruf] Verbindung fehlgeschlagen:", err);
addChatMessage("Anruf-Verbindung fehlgeschlagen.", "system");
}
}
async function teardownCallRoom(notifyServer) {
if (callRoom) {
try { await callRoom.disconnect(); } catch {}
callRoom = null;
}
document.getElementById("callAudioEl")?.remove();
if (notifyServer && activeCallInfo) {
ws.send(JSON.stringify({ type: "phone_call_end", otherUsername: activeCallInfo.otherUsername }));
}
activeCallInfo = null;
document.getElementById("activeCallBar").style.display = "none";
document.getElementById("outgoingCallBox").style.display = "none";
// Nahbereichs-Mikro wieder freigeben, falls es vorher an war
if (voiceRoom && voiceMicEnabled) {
try { await voiceRoom.localParticipant.setMicrophoneEnabled(true); } catch {}
}
}
function endCall() {
teardownCallRoom(true);
}
// -------------------------------------------------------------
// EINSATZ-TABLET (Polizei/Sanitäter/Feuerwehr)
// -------------------------------------------------------------
function openTablet() {
document.getElementById("tabletBox").style.display = "flex";
}
function closeTablet() {
document.getElementById("tabletBox").style.display = "none";
}
// -------------------------------------------------------------
// SUPPORT-TICKETS
// -------------------------------------------------------------
let currentTicketId = null;
let myTicketsCache = [];
// -------------------------------------------------------------
// BÖRSE
// -------------------------------------------------------------
let stockListCache = [];
let currentStockTab = "list";
function openStockBox() {
document.getElementById("stockBox").style.display = "flex";
showStockTab("list");
}
function closeStockBox() {
document.getElementById("stockBox").style.display = "none";
}
function showStockTab(tab) {
currentStockTab = tab;
document.getElementById("stockListPanel").classList.toggle("active", tab === "list");
document.getElementById("stockPortfolioPanel").classList.toggle("active", tab === "portfolio");
document.querySelectorAll("#stockTabs button").forEach((btn, i) => {
btn.classList.toggle("active", (tab === "list" && i === 0) || (tab === "portfolio" && i === 1));
});
if (tab === "list") loadStockList(); else loadStockPortfolio();
}
async function loadStockList() {
const res = await fetch("/api/stocks");
const data = await res.json();
if (!data.ok) return;
stockListCache = data.stocks || [];
const panel = document.getElementById("stockListPanel");
panel.innerHTML = stockListCache.map(s => `
<div class="stock-row">
<div class="stock-info">
<span class="stock-symbol">${escapeHtmlAdmin(s.symbol)}</span>
<span class="stock-name">${escapeHtmlAdmin(s.name)}</span>
</div>
<span class="stock-price">${Number(s.price).toFixed(2)}$</span>
<div class="stock-trade">
<input type="number" id="stockBuyAmount_${s.id}" value="1" min="1">
<button onclick="tradeStock(${s.id}, 'buy')">Kaufen</button>
</div>
</div>
`).join("");
}
async function loadStockPortfolio() {
const data = await ticketFetch("/api/stocks/portfolio");
const panel = document.getElementById("stockPortfolioPanel");
if (!data.ok || !data.portfolio || data.portfolio.length === 0) {
panel.innerHTML = `<p style="color:#888; font-size:12px;">Du besitzt noch keine Aktien.</p>`;
return;
}
panel.innerHTML = data.portfolio.map(p => {
const value = (p.shares * Number(p.price)).toFixed(2);
return `
<div class="stock-row">
<div class="stock-info">
<span class="stock-symbol">${escapeHtmlAdmin(p.symbol)}</span>
<span class="stock-name">${p.shares}x · Wert: ${value}$</span>
</div>
<span class="stock-price">${Number(p.price).toFixed(2)}$</span>
<div class="stock-trade">
<input type="number" id="stockSellAmount_${p.stock_id}" value="1" min="1" max="${p.shares}">
<button class="sell" onclick="tradeStock(${p.stock_id}, 'sell')">Verkaufen</button>
</div>
</div>
`;
}).join("");
}
// -------------------------------------------------------------
// FMS-STATUS
// -------------------------------------------------------------
const FMS_STATUS_LABELS = {
0: "Notruf",
1: "Einsatzbereit Funk",
2: "Einsatzbereit Wache",
3: "Anfahrt zur Einsatzstelle",
4: "Ankunft am Einsatzort",
5: "Sprechwunsch",
6: "Nicht einsatzbereit",
7: "Einsatz erledigt, Rückfahrt",
8: "Sprechwunsch Leitstelle"
};
let myFmsStatus = 2;
function renderFmsStatusButtons() {
const container = document.getElementById("fmsStatusButtons");
container.innerHTML = Object.entries(FMS_STATUS_LABELS).map(([code, label]) => `
<div class="fms-btn ${Number(code) === myFmsStatus ? "active" : ""}" onclick="setFmsStatus(${code})">
<span><span class="fms-code">${code}</span>${escapeHtmlAdmin(label)}</span>
</div>
`).join("");
}
function setFmsStatus(status) {
myFmsStatus = status;
ws.send(JSON.stringify({ type: "set_fms_status", status }));
renderFmsStatusButtons();
}
// -------------------------------------------------------------
// LEITSTELLE
// -------------------------------------------------------------
const LEITSTELLE_STATUS_COLORS = {
0: "#c0392b", 1: "#27ae60", 2: "#27ae60", 3: "#e0a83a",
4: "#3498db", 5: "#9b59b6", 6: "#555", 7: "#e0a83a", 8: "#9b59b6"
};
const JOB_TYPE_ICONS = { police: "🚓", medic: "⛑️", fire: "🚒" };
function openLeitstelle() {
document.getElementById("leitstelleBox").style.display = "flex";
ws.send(JSON.stringify({ type: "leitstelle_open" }));
}
function closeLeitstelle() {
document.getElementById("leitstelleBox").style.display = "none";
ws.send(JSON.stringify({ type: "leitstelle_close" }));
}
function renderLeitstelleUnits(units) {
const list = document.getElementById("leitstelleUnitsList");
if (!units || units.length === 0) {
list.innerHTML = `<p style="color:#888; font-size:12px;">Aktuell keine Einheiten im Dienst.</p>`;
return;
}
list.innerHTML = units.map(u => `
<div class="ls-unit-row">
<span>${JOB_TYPE_ICONS[u.jobType] || ""} <b>${escapeHtmlAdmin(u.username)}</b> · ${escapeHtmlAdmin(u.world)}</span>
<span class="ls-status-badge" style="background:${LEITSTELLE_STATUS_COLORS[u.fmsStatus] || "#555"};">
${u.fmsStatus} · ${escapeHtmlAdmin(FMS_STATUS_LABELS[u.fmsStatus] || "?")}
</span>
</div>
`).join("");
}
function sendLeitstelleDispatch() {
const jobType = document.getElementById("leitstelleTargetJob").value;
const text = document.getElementById("leitstelleDispatchText").value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: "leitstelle_dispatch", jobType, text }));
document.getElementById("leitstelleDispatchText").value = "";
}
function tradeStock(stockId, action) {
const input = document.getElementById(
action === "buy" ? `stockBuyAmount_${stockId}` : `stockSellAmount_${stockId}`
);
const shares = Math.floor(Number(input.value));
if (isNaN(shares) || shares < 1) {
addChatMessage("Ungültige Stückzahl.", "system");
return;
}
ws.send(JSON.stringify({ type: action === "buy" ? "stock_buy" : "stock_sell", stockId, shares }));
// Server antwortet asynchron per Chat-Nachricht - Ansicht kurz danach neu laden
setTimeout(() => showStockTab(currentStockTab), 400);
}
async function ticketFetch(url, options = {}) {
const authToken = localStorage.getItem("token");
options.headers = { ...(options.headers || {}), "Authorization": "Bearer " + authToken };
const res = await fetch(url, options);
return res.json();
}
function showTicketPanel(panelId) {
["ticketListPanel", "ticketNewPanel", "ticketDetailPanel"].forEach(id => {
document.getElementById(id).classList.toggle("active", id === panelId);
});
}
function openTicketBox() {
document.getElementById("ticketBox").style.display = "flex";
loadMyTickets();
}
function closeTicketBox() {
document.getElementById("ticketBox").style.display = "none";
}
async function loadMyTickets() {
showTicketPanel("ticketListPanel");
const data = await ticketFetch("/api/tickets");
if (!data.ok) return;
myTicketsCache = data.tickets || [];
renderTicketList();
}
function renderTicketList() {
const body = document.getElementById("ticketListBody");
if (myTicketsCache.length === 0) {
body.innerHTML = `<p style="color:#888; font-size:12px;">Noch keine Tickets erstellt.</p>`;
return;
}
const statusLabels = { open: "Offen", in_progress: "In Bearbeitung", closed: "Geschlossen" };
body.innerHTML = myTicketsCache.map(t => `
<div class="ticket-row" onclick="openTicketDetail(${t.id})">
<div class="ticket-row-top">
<span>${escapeHtmlAdmin(t.subject)}</span>
<span class="ticket-status ${t.status}">${statusLabels[t.status] || t.status}</span>
</div>
<div class="ticket-row-meta">#${t.id} · ${escapeHtmlAdmin(t.category)} · zuletzt aktualisiert ${new Date(t.updated_at).toLocaleString("de-DE")}</div>
</div>
`).join("");
}
function openNewTicketPanel() {
document.getElementById("ticketNewSubject").value = "";
document.getElementById("ticketNewMessage").value = "";
showTicketPanel("ticketNewPanel");
}
function backToTicketList() {
currentTicketId = null;
loadMyTickets();
}
async function submitNewTicket() {
const category = document.getElementById("ticketNewCategory").value;
const subject = document.getElementById("ticketNewSubject").value.trim();
const message = document.getElementById("ticketNewMessage").value.trim();
if (!subject || !message) {
showTicketMsg("ticketNewMsg", "Betreff und Nachricht sind Pflicht.", true);
return;
}
const data = await ticketFetch("/api/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject, category, message })
});
if (data.ok) {
await loadMyTickets();
openTicketDetail(data.id);
} else {
showTicketMsg("ticketNewMsg", "Fehler: " + (data.error || "unbekannt"), true);
}
}
async function openTicketDetail(ticketId) {
currentTicketId = ticketId;
showTicketPanel("ticketDetailPanel");
const data = await ticketFetch("/api/tickets/" + ticketId);
if (!data.ok) {
addChatMessage("Ticket konnte nicht geladen werden: " + (data.error || "unbekannt"), "system");
backToTicketList();
return;
}
const statusLabels = { open: "Offen", in_progress: "In Bearbeitung", closed: "Geschlossen" };
document.getElementById("ticketDetailHeader").innerHTML =
`<b>#${data.ticket.id} ${escapeHtmlAdmin(data.ticket.subject)}</b><br>Status: ${statusLabels[data.ticket.status] || data.ticket.status}`;
const myUsername = player.username;
const msgContainer = document.getElementById("ticketMessages");
msgContainer.innerHTML = data.messages.map(m => `
<div class="ticket-msg ${m.username === myUsername ? "mine" : "theirs"}">
<div class="ticket-msg-meta">${escapeHtmlAdmin(m.username)}${m.is_admin_reply ? " (Team)" : ""} · ${new Date(m.created_at).toLocaleString("de-DE")}</div>
${escapeHtmlAdmin(m.message)}
</div>
`).join("");
msgContainer.scrollTop = msgContainer.scrollHeight;
const replyRow = document.getElementById("ticketReplyRow");
replyRow.style.display = data.ticket.status === "closed" ? "none" : "flex";
}
async function submitTicketReply() {
if (!currentTicketId) return;
const text = document.getElementById("ticketReplyText").value.trim();
if (!text) return;
const data = await ticketFetch(`/api/tickets/${currentTicketId}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text })
});
if (data.ok) {
document.getElementById("ticketReplyText").value = "";
openTicketDetail(currentTicketId);
} else {
addChatMessage("Antwort fehlgeschlagen: " + (data.error || "unbekannt"), "system");
}
}
function showTicketMsg(elId, text, isError) {
const el = document.getElementById(elId);
el.style.color = isError ? "#e06c6c" : "#6fbf73";
el.textContent = text;
setTimeout(() => { el.textContent = ""; }, 4000);
}
document.querySelectorAll(".tablet-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tablet-tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tablet-panel").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById("tabletPanel-" + btn.dataset.tabletTab).classList.add("active");
if (btn.dataset.tabletTab === "fms") renderFmsStatusButtons();
});
});
function tabletSearchPerson() {
const username = document.getElementById("tabletSearchInput").value.trim();
if (!username) return;
ws.send(JSON.stringify({ type: "tablet_search_person", username }));
}
function renderTabletSearchResult(data) {
const el = document.getElementById("tabletSearchResult");
if (!data.found) {
el.innerHTML = `<div class="tablet-result-card">Kein Spieler namens "${escapeHtmlAdmin(data.username)}" gefunden.</div>`;
return;
}
const wantedText = data.wantedLevel > 0
? `<span class="wanted">⭐ Gesucht (Level ${data.wantedLevel})</span>`
: `<span class="clean">Nicht gesucht</span>`;
el.innerHTML = `
<div class="tablet-result-card">
<b>${escapeHtmlAdmin(data.username)}</b> <span style="color:#888;">(ID: ${data.playerId})</span><br>
Status: ${data.online ? `🟢 Online (${escapeHtmlAdmin(data.world || "?")})` : "⚫ Offline"}<br>
Fahndung: ${wantedText}<br>
Kopfgeld: ${data.bounty > 0 ? `💀 ${data.bounty}$` : "-"}<br>
Leben: ${data.health}%<br>
${data.jailed ? '<span class="wanted">🔒 In Haft</span><br>' : ""}
${data.banned ? '<span class="wanted">🚫 Gebannt</span><br>' : ""}
</div>
`;
}
function tabletLoadWantedList() {
ws.send(JSON.stringify({ type: "tablet_wanted_list" }));
}
function renderTabletWantedList(wanted) {
const el = document.getElementById("tabletWantedList");
if (!wanted || wanted.length === 0) {
el.innerHTML = `<p style="color:#6b8b93; font-size:12px;">Aktuell ist niemand gesucht.</p>`;
return;
}
el.innerHTML = "";
wanted.forEach(w => {
const row = document.createElement("div");
row.className = "wanted-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(w.username)} ${"⭐".repeat(w.wantedLevel)} (${escapeHtmlAdmin(w.world)})</span>
<span>${w.bounty > 0 ? `💀 ${w.bounty}$` : ""}</span>
`;
el.appendChild(row);
});
}
function tabletLookupPlate() {
const plate = document.getElementById("tabletPlateInput").value.trim();
if (!plate) return;
ws.send(JSON.stringify({ type: "tablet_plate_lookup", plate }));
}
function renderTabletPlateResult(data) {
const el = document.getElementById("tabletPlateResult");
if (!data.found) {
el.innerHTML = `<div class="tablet-result-card">Kein Fahrzeug mit Kennzeichen "${escapeHtmlAdmin(data.plate)}" gefunden.</div>`;
return;
}
const wantedText = data.ownerWanted > 0
? `<span class="wanted">⭐ Gesucht (Level ${data.ownerWanted})</span>`
: `<span class="clean">Nicht gesucht</span>`;
el.innerHTML = `
<div class="tablet-result-card">
Kennzeichen: <b>${escapeHtmlAdmin(data.plate)}</b><br>
Fahrzeug: ${escapeHtmlAdmin(data.model)}${data.isTrailer ? " (Anhänger)" : ""}<br>
Halter: ${escapeHtmlAdmin(data.ownerName)}<br>
Fahndung des Halters: ${wantedText}<br>
Kopfgeld auf Halter: ${data.ownerBounty > 0 ? `💀 ${data.ownerBounty}$` : "-"}<br>
Welt: ${escapeHtmlAdmin(data.world)}
</div>
`;
}
function cycleRadio() {
if (radioStations.length === 0) {
addChatMessage("Keine Radiosender eingerichtet.", "system");
return;
}
currentRadioIndex++;
if (currentRadioIndex >= radioStations.length) currentRadioIndex = -1;
updateRadioPlayback();
}
function updateRadioPlayback() {
const label = document.getElementById("hudRadioLabel");
if (currentRadioIndex === -1) {
radioAudio.pause();
radioAudio.src = "";
if (label) label.textContent = "📻 Aus";
return;
}
const station = radioStations[currentRadioIndex];
radioAudio.src = station.url;
radioAudio.volume = 0.5;
radioAudio.play().catch(() => {
addChatMessage(`Radiosender "${station.name}" konnte nicht abgespielt werden.`, "system");
});
if (label) label.textContent = `📻 ${station.name}`;
}
function stopRadio() {
currentRadioIndex = -1;
radioAudio.pause();
radioAudio.src = "";
}
// -------------------------------------------------------------
// 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;
}
});
console.log("TileConfig geladen:", tileConfig);
markLoadingStepDone("tileConfig", "Karten-Daten geladen...");
})
.catch(() => markLoadingStepDone("tileConfig"));
// -------------------------------------------------------------
// REGISTER
// -------------------------------------------------------------
regBtn.onclick = async () => {
const username = regUser.value;
const password = regPass.value;
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!data.ok) {
alert(data.error);
return;
}
alert("Registrierung erfolgreich! Ein Admin muss deinen Account noch freischalten, bevor du dich einloggen kannst.");
registerBox.style.display = "none";
loginBox.style.display = "block";
};
// -------------------------------------------------------------
// LOGIN
// -------------------------------------------------------------
loginBtn.onclick = async () => {
const username = loginUser.value;
const password = loginPass.value;
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!data.ok) {
alert(data.error);
return;
}
// Token für spätere Besuche speichern (Startseite + Spiel greifen darauf zu)
localStorage.setItem("token", data.token);
if (data.username) localStorage.setItem("username", data.username);
localStorage.setItem("isAdmin", data.isAdmin ? "true" : "false");
loginBox.style.display = "none";
canvas.style.display = "block";
showLoadingScreen();
startMultiplayer(data.token);
};
// -------------------------------------------------------------
// UI SWITCH
// -------------------------------------------------------------
switchToRegister.onclick = () => {
loginBox.style.display = "none";
registerBox.style.display = "block";
canvas.style.display = "none";
};
switchToLogin.onclick = () => {
registerBox.style.display = "none";
loginBox.style.display = "block";
canvas.style.display = "none";
};
// -------------------------------------------------------------
// MULTIPLAYER STARTEN
// -------------------------------------------------------------
function startMultiplayer(token) {
ws = new WebSocket("wss://server.borderville.de");
ws.onopen = () => {
ws.send(JSON.stringify({ type: "auth", token }));
};
ws.onclose = () => {
if (!loggedIn) {
// Verbindung/Login fehlgeschlagen (z.B. abgelaufener/ungültiger Token)
// -> gespeicherten Token verwerfen und zur Startseite weiterleiten
// (Login gibt es nur noch dort, nicht mehr auf der Spielseite)
localStorage.removeItem("token");
localStorage.removeItem("isAdmin");
localStorage.removeItem("username");
location.href = "/index.html?loginfailed=1";
} else {
// Verbindung wurde mitten im Spiel verloren (Server neu gestartet,
// Netzwerkproblem, Sperrung etc.) -> zurück zur Startseite
location.href = "/index.html?disconnected=1";
}
};
ws.onmessage = ev => {
const msg = JSON.parse(ev.data);
if (msg.type === "auth_error") {
alert(msg.msg || "Login fehlgeschlagen.");
}
if (msg.type === "auth_ok") {
player.id = msg.id;
player.username = msg.username;
player.isAdmin = !!msg.isAdmin;
maps = msg.maps;
document.getElementById("topMenu").style.display = "flex";
document.getElementById("statusWindow").style.display = "block";
document.getElementById("inventoryWindow").style.display = "block";
document.getElementById("chatBox").style.display = "flex";
player.inventory = msg.inventory || [];
document.getElementById("inventoryWindow").style.display = "block";
renderInventoryWindow();
canvas.style.display = "block";
console.log("MAPS GELADEN:", maps);
const map = maps[player.world];
if (map && map.spawn) {
player.x = map.spawn.x;
player.y = map.spawn.y;
}
loggedIn = true;
markLoadingStepDone("auth", "Anmeldung erfolgreich...");
return;
}
if (msg.type === "state") {
lastServerUpdate = Date.now();
const me = msg.players.find(p => p.id === player.id);
if (me) {
// nur setzen, wenn wir NICHT gerade bewegen (und nicht fahren)
if (passengerCarId || (!drivingCarId && !keys["w"] && !keys["a"] && !keys["s"] && !keys["d"] && !touchMove.w && !touchMove.a && !touchMove.s && !touchMove.d)) {
player.x = me.x;
player.y = me.y;
}
if (voiceCurrentWorld !== me.world) {
connectVoiceChat(me.world); // Welt gewechselt (oder Erstverbindung) -> Sprachchat-Raum wechseln
}
player.world = me.world;
player.money = me.money;
player.bank = me.bank;
player.health = me.health;
player.hunger = me.hunger;
player.thirst = me.thirst;
player.inventory = me.inventory;
player.hasPackage = me.hasPackage;
if (!playerStateLoaded) {
playerStateLoaded = true;
markLoadingStepDone("playerState", "Spielerdaten geladen...");
}
player.color = me.color || "#f1c40f";
player.skin = me.skin || "none";
player.wantedLevel = me.wantedLevel || 0;
player.cuffed = !!me.cuffed;
player.inSafeZone = !!me.inSafeZone;
player.shirtColor = me.shirtColor;
player.pantsColor = me.pantsColor;
player.shoesColor = me.shoesColor;
player.shirtImage = me.shirtImage;
player.pantsImage = me.pantsImage;
player.shoesImage = me.shoesImage;
player.skinImage = me.skinImage;
player.helmetImage = me.helmetImage;
player.title = me.title || null;
player.jobType = me.jobType || null;
player.jobName = me.jobName || null;
const oldLevel = player.level;
player.xp = me.xp || 0;
player.level = me.level || 1;
if (oldLevel !== undefined && player.level > oldLevel) {
// Level-Up-Glow kurz anzeigen
const el = document.getElementById("levelDisplay");
if (el) {
el.classList.add("level-up-flash");
setTimeout(() => el.classList.remove("level-up-flash"), 1500);
}
}
updateLevelDisplay();
player.gangTag = me.gangTag || null;
player.gangColor = me.gangColor || null;
updatePvpDisplay();
updateWantedDisplay();
}
otherPlayers = msg.players.filter(p => p.id !== player.id);
updateOnlineCount(msg.players.length);
renderInventoryWindow();
}
// AUTOS (NEU)
if (msg.type === "cars") {
cars = msg.cars;
const mine = cars.find(c => c.driverId === player.id);
const wasDriving = !!drivingCarId;
drivingCarId = mine ? mine.id : null;
if (mine) { player.x = mine.x; player.y = mine.y; }
if (wasDriving && !drivingCarId) stopRadio();
const myPassengerCar = cars.find(c => c.passengerId === player.id);
passengerCarId = myPassengerCar ? myPassengerCar.id : null;
if (myPassengerCar) { player.x = myPassengerCar.x; player.y = myPassengerCar.y; }
updateCarHud(mine || myPassengerCar);
}
if (msg.type === "shop_info") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "garage_open") {
openGarageWindow(msg.cars);
}
if (msg.type === "garage_info") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "jobcenter_open") {
openJobcenterWindow(msg.jobs, msg.currentRankId);
}
if (msg.type === "jobcenter_info") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "world_info") {
gameHour = msg.hour;
weather = msg.weather;
updateClockDisplay();
}
if (msg.type === "crime_alert") {
crimeAlerts.push({
world: msg.world,
x: msg.x,
y: msg.y,
kind: "crime",
expiresAt: Date.now() + 60000
});
addChatMessage(`🚨 ${msg.crimeType} gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "server_restart_now") {
addChatMessage("🔄 Server wird jetzt neu gestartet - du wirst zur Startseite weitergeleitet...", "system");
setTimeout(() => {
location.href = "/index.html?restarted=1";
}, 1200);
}
if (msg.type === "player_died") {
showDeathScreen(msg.killerName);
}
if (msg.type === "jail_status") {
jailedUntil = msg.jailedUntil;
updateJailDisplay();
}
if (msg.type === "admin_duty_status") {
player.adminDuty = msg.onDuty;
updateDutyDisplay();
}
if (msg.type === "medic_alert") {
crimeAlerts.push({
world: msg.world,
x: msg.x,
y: msg.y,
kind: "medic",
expiresAt: Date.now() + 60000
});
addChatMessage(`⛑️ ${msg.crimeType} gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "fire_update") {
fires = msg.fires || [];
}
if (msg.type === "ground_drop_update") {
groundDrops = msg.drops || [];
}
if (msg.type === "dealer_update") {
dealerSpots = msg.spots || [];
}
if (msg.type === "fire_alert") {
addChatMessage(`🔥 Feuer gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "event_update") {
activeEvents = msg.events;
updateEventDisplay();
}
if (msg.type === "radio_message") {
addChatMessage(`${msg.icon} [${msg.jobName}-Funk] ${msg.username}: ${msg.text}`, "radio");
}
// -------------------------------------------------------
// HANDY: KONTAKTE / SMS
// -------------------------------------------------------
if (msg.type === "phone_contacts_data") {
phoneContacts = msg.contacts || [];
phonePendingRequests = msg.pendingRequests || [];
renderPhoneContacts();
}
if (msg.type === "phone_sms_sent") {
if (currentSmsContact === msg.username) {
ws.send(JSON.stringify({ type: "phone_get_messages", username: msg.username }));
}
}
if (msg.type === "phone_sms_received") {
addChatMessage(`📩 SMS von ${msg.username}: ${msg.text}`, "system");
if (currentSmsContact === msg.username) {
ws.send(JSON.stringify({ type: "phone_get_messages", username: msg.username }));
}
}
if (msg.type === "phone_messages_data") {
if (currentSmsContact === msg.username) {
renderSmsThread(msg.messages || []);
}
}
// -------------------------------------------------------
// HANDY: ANRUFE
// -------------------------------------------------------
if (msg.type === "incoming_call") {
showIncomingCall(msg.callId, msg.callerName);
}
if (msg.type === "call_ringing") {
document.getElementById("outgoingCallText").textContent = `📞 Rufe ${msg.username} an...`;
document.getElementById("outgoingCallBox").style.display = "block";
}
if (msg.type === "call_connected") {
connectToCallRoom(msg);
}
if (msg.type === "call_declined") {
document.getElementById("outgoingCallBox").style.display = "none";
addChatMessage("Anruf wurde abgelehnt.", "system");
}
if (msg.type === "call_missed") {
document.getElementById("outgoingCallBox").style.display = "none";
addChatMessage(`Verpasster Anruf bei ${msg.username} (keine Antwort).`, "system");
}
if (msg.type === "call_cancelled") {
hideIncomingCall();
document.getElementById("outgoingCallBox").style.display = "none";
}
if (msg.type === "call_ended") {
teardownCallRoom(false);
addChatMessage("Anruf beendet.", "system");
}
if (msg.type === "clothing_shop_open") {
openClothingShop(msg.catalog);
}
if (msg.type === "trailer_shop_open") {
openTrailerShop(msg.catalog);
}
if (msg.type === "close_window") {
const el = document.getElementById(msg.windowType);
if (el) el.style.display = "none";
if (msg.reason === "cuffed") {
addChatMessage("🔗 Fenster geschlossen - du wurdest gefesselt.", "system");
}
}
if (msg.type === "leitstelle_units") {
renderLeitstelleUnits(msg.units);
}
if (msg.type === "ticket_update") {
if (msg.eventType === "ticket_reply" && msg.ticketId === currentTicketId) {
openTicketDetail(currentTicketId); // aktuell offenes Ticket live aktualisieren
} else {
addChatMessage("🎫 Ein Ticket wurde aktualisiert.", "system");
}
}
if (msg.type === "tablet_person_result") {
renderTabletSearchResult(msg);
}
if (msg.type === "tablet_wanted_list_result") {
renderTabletWantedList(msg.wanted);
}
if (msg.type === "tablet_plate_result") {
renderTabletPlateResult(msg);
}
if (msg.type === "zone_update") {
territoryZones = msg.zones || [];
}
if (msg.type === "gate_toggle") {
const map = maps[player.world];
if (map && map.objects && map.objects[msg.objectIndex]) {
map.objects[msg.objectIndex].open = msg.open;
}
}
if (msg.type === "taxi_requests") {
openTaxiWindow(msg.requests);
}
if (msg.type === "job_menu_data") {
renderJobMenu(msg);
}
if (msg.type === "tune_menu_data") {
openTuneWindow(msg);
}
if (msg.type === "admin_players") {
renderAdminPlayerList(msg.players);
}
if (msg.type === "jobcenter_error") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "house_open") {
openHouseWindow(msg);
}
if (msg.type === "wardrobe_open") {
openWardrobeWindow(msg);
}
if (msg.type === "trunk_open") {
openTrunkWindow(msg.carId, msg.trunk, msg.inventory);
}
if (msg.type === "trunk_error") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "npc_dialog") {
addChatMessage("NPC: " + msg.text, "system");
}
if (msg.type === "shop_open") {
const shopWindow = document.getElementById("shopWindow");
const shopList = document.getElementById("shopList");
shopWindow.style.display = "block";
shopList.innerHTML = "";
currentShopIsOwner = !!msg.isOwner;
if (msg.isOwner) {
const hint = document.createElement("div");
hint.style.cssText = "padding:6px; margin-bottom:8px; color:#7fb0ec; font-size:12px;";
hint.textContent = "🏪 Das ist dein Shop - du kannst die Preise deiner Artikel selbst festlegen.";
shopList.appendChild(hint);
} else if (!msg.ownerId && msg.purchasePrice) {
const hint = document.createElement("div");
hint.style.cssText = "padding:6px; margin-bottom:8px; color:#e0a83a; font-size:12px;";
hint.innerHTML = `🏪 Dieser Shop ist unverkauft - kostet <b>${msg.purchasePrice}$</b> (<code>/shop buy</code>).`;
shopList.appendChild(hint);
}
msg.items.forEach(item => {
const div = document.createElement("div");
div.style.padding = "5px";
div.style.borderBottom = "1px solid #333";
if (currentShopIsOwner) {
div.innerHTML = `
${item.name} -
<input type="number" id="shopPriceInput_${item.id}" value="${item.price}" min="1" max="50000" style="width:80px;">$
<button onclick="setShopItemPrice('${item.id}')">Preis speichern</button>
`;
} else {
div.innerHTML = `
${item.name} - ${item.price}$
<button onclick="buyItem('${item.id}')">Kaufen</button>
`;
}
shopList.appendChild(div);
});
}
if (msg.type === "shop_price_updated") {
addChatMessage(`🏪 Preis aktualisiert: ${msg.price}$`, "system");
}
if (msg.type === "shop_error") {
addChatMessage(msg.msg, "system");
}
if (msg.type === "item_used") {
player.hunger = msg.hunger;
player.thirst = msg.thirst;
player.health = msg.health;
player.inventory = msg.inventory;
updateStatusWindow();
renderInventoryWindow();
}
if (msg.type === "atm_open") {
const pin = prompt("PIN eingeben");
ws.send(JSON.stringify({ type: "bank_pin_check", pin }));
}
if (msg.type === "bank_pin_ok") {
ws.send(JSON.stringify({ type: "bank_open" }));
}
if (msg.type === "bank_pin_fail") {
addChatMessage("Falsche PIN!", "system");
}
if (msg.type === "bank_pin_set_result") {
const el = document.getElementById("atmPinMsg");
if (el) {
el.textContent = msg.msg;
el.style.color = msg.ok ? "#6fbf73" : "#e06c6c";
}
if (msg.ok) {
document.getElementById("atmOldPin").value = "";
document.getElementById("atmNewPin").value = "";
}
}
if (msg.type === "bank_open") {
openATMWindow(msg.money, msg.bank);
}
if (msg.type === "bank_update") {
document.getElementById("atmMoney").innerText = msg.money;
document.getElementById("atmBank").innerText = msg.bank;
}
if (msg.type === "object_interact") {
if (msg.action === "loot") {
addChatMessage("Du hast die Kiste geöffnet!", "system");
}
}
if (msg.type === "chat_message") {
if (msg.system) {
addChatMessage(msg.text, "system");
} else {
addChatMessage(msg.text, "player", msg.from);
}
}
// Map-Daten
if (msg.type === "map_data") {
tiles = msg.tiles;
doors = msg.doors || [];
objects = msg.objects || [];
shops = msg.shops || [];
garages = msg.garages || [];
jobcenters = msg.jobcenters || [];
gasStations = msg.gasStations || [];
clothingShops = msg.clothingShops || [];
trailerShops = msg.trailerShops || [];
highwayLinks = msg.highwayLinks || [];
blackMarketSpots = msg.blackMarketSpots || [];
insuranceOffices = msg.insuranceOffices || [];
plateOffices = msg.plateOffices || [];
repairShops = msg.repairShops || [];
houses = msg.houses || [];
taxiStands = msg.taxiStands || [];
hospitals = msg.hospitals || [];
prisons = msg.prisons || [];
territoryZones = msg.territoryZones || [];
impoundLots = msg.impoundLots || [];
fireStations = msg.fireStations || [];
harvestSpots = msg.harvestSpots || [];
processSpots = msg.processSpots || [];
dealerSpots = msg.dealerSpots || [];
fires = msg.fires || [];
groundDrops = msg.groundDrops || [];
jobPoints = msg.jobPoints || [];
atms = msg.atms || [];
spawn = msg.spawn;
// WICHTIG: maps[player.world] mitpflegen - renderMap(), Kollisionsprüfung,
// Tür-/Tor-Erkennung usw. lesen ihre Daten direkt von dort, nicht von den
// obigen Einzelvariablen. Ohne das hier würden neue/aktualisierte Welten
// (z.B. Hausinneres, frisch im Editor gespeicherte Karte) nicht sichtbar.
maps[player.world] = {
tiles: msg.tiles,
tileRot: msg.tileRot || null,
doors: msg.doors || [],
objects: msg.objects || [],
atms: msg.atms || [],
spawn: msg.spawn
};
return;
}
};
}
function openATMWindow(money, bank) {
document.getElementById("atmMoney").innerText = money;
document.getElementById("atmBank").innerText = bank;
document.getElementById("atmBox").style.display = "block";
}
function closeATM() {
document.getElementById("atmBox").style.display = "none";
}
function atmDeposit() {
const amount = Number(document.getElementById("atmAmount").value);
ws.send(JSON.stringify({ type: "bank_deposit", amount }));
}
function atmWithdraw() {
const amount = Number(document.getElementById("atmAmount").value);
ws.send(JSON.stringify({ type: "bank_withdraw", amount }));
}
function atmTransfer() {
const amount = Number(document.getElementById("atmAmount").value);
const targetId = Number(document.getElementById("atmTarget").value);
ws.send(JSON.stringify({ type: "bank_transfer", amount, targetId }));
}
function atmChangePin() {
const oldPin = document.getElementById("atmOldPin").value.trim();
const newPin = document.getElementById("atmNewPin").value.trim();
if (!/^\d{4}$/.test(newPin)) {
document.getElementById("atmPinMsg").textContent = "Neue PIN muss genau 4 Ziffern haben.";
document.getElementById("atmPinMsg").style.color = "#e06c6c";
return;
}
ws.send(JSON.stringify({ type: "bank_set_pin", oldPin, newPin }));
}
// -------------------------------------------------------------
// GARAGE (NEU)
// -------------------------------------------------------------
function openGarageWindow(carList) {
const box = document.getElementById("garageBox");
const list = document.getElementById("garageList");
list.innerHTML = "";
carList.forEach(c => {
const div = document.createElement("div");
div.style.padding = "5px";
div.style.borderBottom = "1px solid #333";
div.innerHTML = `
${c.model} (${c.is_stored ? "eingelagert" : "unterwegs"})
${c.is_stored
? `<button class="garageRetrieveBtn" data-id="${c.id}">Herausholen</button>`
: `<button class="garageStoreBtn" data-id="${c.id}">Einlagern</button>`}
`;
list.appendChild(div);
});
// Event-Delegation (wie beim Inventar) statt Klick-Handler pro Button
if (!list.dataset.bound) {
list.addEventListener("click", e => {
const storeBtn = e.target.closest(".garageStoreBtn");
const retrieveBtn = e.target.closest(".garageRetrieveBtn");
if (storeBtn) {
ws.send(JSON.stringify({ type: "garage_store", carId: Number(storeBtn.dataset.id) }));
}
if (retrieveBtn) {
ws.send(JSON.stringify({ type: "garage_retrieve", carId: Number(retrieveBtn.dataset.id) }));
}
});
list.dataset.bound = "true";
}
box.style.display = "block";
}
function closeGarage() {
document.getElementById("garageBox").style.display = "none";
}
// -------------------------------------------------------------
// AUTO-HUD: Tacho + Tankanzeige (NEU)
// -------------------------------------------------------------
function updateCarHud(myCar) {
const hud = document.getElementById("carHud");
if (!hud) return;
if (!myCar) {
hud.style.display = "none";
return;
}
hud.style.display = "block";
document.getElementById("hudSpeed").textContent = myCar.speedKmh ?? 0;
const pct = myCar.tankSize > 0 ? Math.max(0, Math.min(100, (myCar.fuel / myCar.tankSize) * 100)) : 0;
document.getElementById("hudFuelBarInner").style.width = pct + "%";
document.getElementById("hudFuelBarInner").style.background =
pct > 30 ? "#4caf50" : (pct > 12 ? "#e0a02c" : "#c0392b");
document.getElementById("hudFuelText").textContent =
`${myCar.fuel.toFixed(1)} / ${myCar.tankSize}L`;
const healthPct = Math.max(0, Math.min(100, myCar.health ?? 100));
document.getElementById("hudHealthBarInner").style.width = healthPct + "%";
document.getElementById("hudHealthBarInner").style.background =
healthPct > 50 ? "#4caf50" : (healthPct > 20 ? "#e0a02c" : "#c0392b");
document.getElementById("hudHealthText").textContent = Math.round(healthPct) + "%";
const odoEl = document.getElementById("hudOdometer");
if (odoEl) odoEl.textContent = (myCar.odometer ?? 0).toFixed(1) + " km";
const insEl = document.getElementById("hudInsurance");
if (insEl) insEl.textContent = myCar.insured ? "🛡️ Versichert" : "⚠️ Unversichert";
}
// -------------------------------------------------------------
// JOBCENTER (NEU)
// -------------------------------------------------------------
function openJobcenterWindow(jobList, currentRankId) {
const box = document.getElementById("jobBox");
const list = document.getElementById("jobList");
list.innerHTML = "";
jobList.forEach(job => {
const jobDiv = document.createElement("div");
jobDiv.style.marginBottom = "10px";
const ranksHtml = job.ranks.map(r => {
const isCurrent = r.id === currentRankId;
const isEntry = r.level === 1;
let btn = "";
if (isCurrent) {
btn = `<span style="color:#6fbf73;">aktueller Job</span>`;
} else if (isEntry) {
btn = `<button class="jobApplyBtn" data-id="${r.id}">Bewerben</button>`;
} else {
btn = `<span style="color:#666;">nur per Beförderung</span>`;
}
return `
<div style="display:flex; justify-content:space-between; padding:3px 0;">
<span>Rang ${r.level}: ${r.title} (${r.salary}$/Min)</span>
${btn}
</div>
`;
}).join("");
jobDiv.innerHTML = `
<div style="font-weight:bold; border-bottom:1px solid #333; margin-bottom:4px;">${job.name}</div>
${ranksHtml}
`;
list.appendChild(jobDiv);
});
if (currentRankId) {
const quitDiv = document.createElement("div");
quitDiv.style.marginTop = "10px";
quitDiv.innerHTML = `<button id="jobQuitBtn" style="background:#a83232; width:100%;">Job kündigen</button>`;
list.appendChild(quitDiv);
}
// Event-Delegation (wie bei Inventar/Garage) statt Handler pro Button
if (!list.dataset.bound) {
list.addEventListener("click", e => {
const applyBtn = e.target.closest(".jobApplyBtn");
const quitBtn = e.target.closest("#jobQuitBtn");
if (applyBtn) {
ws.send(JSON.stringify({ type: "job_apply", rankId: Number(applyBtn.dataset.id) }));
}
if (quitBtn) {
if (confirm("Job wirklich kündigen?")) {
ws.send(JSON.stringify({ type: "job_quit" }));
}
}
});
list.dataset.bound = "true";
}
box.style.display = "block";
}
function closeJobcenter() {
document.getElementById("jobBox").style.display = "none";
}
// -------------------------------------------------------------
// KOFFERRAUM (NEU)
// -------------------------------------------------------------
let currentTrunkCarId = null;
function openTrunkWindow(carId, trunk, inventory) {
currentTrunkCarId = carId;
player.inventory = inventory || player.inventory;
const box = document.getElementById("trunkBox");
const invList = document.getElementById("trunkInventoryList");
const trunkList = document.getElementById("trunkContentList");
invList.innerHTML = "";
(inventory || []).forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${item.name || item.id} x${item.amount}</span>
<button class="trunkStoreBtn" data-id="${item.id}">→ Kofferraum</button>
`;
invList.appendChild(div);
});
trunkList.innerHTML = "";
(trunk || []).forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${item.name || item.id} x${item.amount}</span>
<button class="trunkTakeBtn" data-id="${item.id}">→ Inventar</button>
`;
trunkList.appendChild(div);
});
if (!box.dataset.bound) {
box.addEventListener("click", e => {
const storeBtn = e.target.closest(".trunkStoreBtn");
const takeBtn = e.target.closest(".trunkTakeBtn");
if (storeBtn) {
ws.send(JSON.stringify({ type: "trunk_store", itemId: storeBtn.dataset.id, amount: 1 }));
}
if (takeBtn) {
ws.send(JSON.stringify({ type: "trunk_take", itemId: takeBtn.dataset.id, amount: 1 }));
}
});
box.dataset.bound = "true";
}
box.style.display = "block";
}
function closeTrunk() {
document.getElementById("trunkBox").style.display = "none";
currentTrunkCarId = null;
}
// -------------------------------------------------------------
// HÄUSER (NEU)
// -------------------------------------------------------------
let currentHouseId = null;
function openHouseWindow(msg) {
currentHouseId = msg.houseId;
const box = document.getElementById("houseBox");
document.getElementById("houseName").textContent = msg.name;
const buySection = document.getElementById("houseBuySection");
const storageSection = document.getElementById("houseStorageSection");
const lockedSection = document.getElementById("houseLockedSection");
const forRentSection = document.getElementById("houseForRentSection");
buySection.classList.add("hidden");
storageSection.classList.add("hidden");
lockedSection.classList.add("hidden");
forRentSection.classList.add("hidden");
if (!msg.owned) {
buySection.classList.remove("hidden");
document.getElementById("housePrice").textContent = msg.price + "$";
} else if (msg.hasKey) {
storageSection.classList.remove("hidden");
renderHouseStorage(msg.storage || []);
} else if (msg.forRent) {
forRentSection.classList.remove("hidden");
document.getElementById("houseRentPrice").textContent = msg.rentPrice + "$";
} else {
lockedSection.classList.remove("hidden");
}
if (!box.dataset.bound) {
box.addEventListener("click", e => {
const storeBtn = e.target.closest(".houseStoreBtn");
const takeBtn = e.target.closest(".houseTakeBtn");
if (storeBtn) {
ws.send(JSON.stringify({ type: "house_store", itemId: storeBtn.dataset.id, amount: 1 }));
}
if (takeBtn) {
ws.send(JSON.stringify({ type: "house_take", itemId: takeBtn.dataset.id, amount: 1 }));
}
});
box.dataset.bound = "true";
}
box.style.display = "block";
}
function renderHouseStorage(storage) {
const invList = document.getElementById("houseInventoryList");
const stoList = document.getElementById("houseStorageList");
invList.innerHTML = "";
(player.inventory || []).forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${item.name || item.id} x${item.amount}</span>
<button class="houseStoreBtn" data-id="${item.id}">→ Haus</button>
`;
invList.appendChild(div);
});
stoList.innerHTML = "";
storage.forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${item.name || item.id} x${item.amount}</span>
<button class="houseTakeBtn" data-id="${item.id}">→ Inventar</button>
`;
stoList.appendChild(div);
});
}
// -------------------------------------------------------------
// KLEIDERSCHRANK (eigenständig, getrennt von der Haus-Lagerkiste)
// -------------------------------------------------------------
function itemDisplayName(item) {
const clothingMatch = String(item.id).match(/^clothing_(\d+)$/);
if (clothingMatch) {
const catalogItem = clothingCatalogCache[Number(clothingMatch[1])];
return catalogItem ? ("👕 " + catalogItem.name) : item.id;
}
return item.name || item.id;
}
function openWardrobeWindow(msg) {
const box = document.getElementById("wardrobeBox");
renderWardrobe(msg.wardrobe || []);
if (!box.dataset.bound) {
box.addEventListener("click", e => {
const storeBtn = e.target.closest(".wardrobeStoreBtn");
const takeBtn = e.target.closest(".wardrobeTakeBtn");
if (storeBtn) {
ws.send(JSON.stringify({ type: "wardrobe_store", itemId: storeBtn.dataset.id, amount: 1 }));
}
if (takeBtn) {
ws.send(JSON.stringify({ type: "wardrobe_take", itemId: takeBtn.dataset.id, amount: 1 }));
}
});
box.dataset.bound = "true";
}
box.style.display = "block";
}
function closeWardrobeWindow() {
document.getElementById("wardrobeBox").style.display = "none";
}
function renderWardrobe(wardrobe) {
const invList = document.getElementById("wardrobeInventoryList");
const wList = document.getElementById("wardrobeStorageList");
invList.innerHTML = "";
(player.inventory || []).forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${escapeHtmlAdmin(itemDisplayName(item))} x${item.amount}</span>
<button class="wardrobeStoreBtn" data-id="${item.id}">→ Schrank</button>
`;
invList.appendChild(div);
});
wList.innerHTML = "";
wardrobe.forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "3px 0";
div.innerHTML = `
<span>${escapeHtmlAdmin(itemDisplayName(item))} x${item.amount}</span>
<button class="wardrobeTakeBtn" data-id="${item.id}">→ Inventar</button>
`;
wList.appendChild(div);
});
}
function buyCurrentHouse() {
if (currentHouseId === null) return;
ws.send(JSON.stringify({ type: "house_buy", houseId: currentHouseId }));
}
function rentCurrentHouse() {
ws.send(JSON.stringify({ type: "house_rent" }));
document.getElementById("houseBox").style.display = "none";
}
function closeHouse() {
document.getElementById("houseBox").style.display = "none";
currentHouseId = null;
}
// -------------------------------------------------------------
// IN-GAME ADMIN-MENÜ (NEU)
// -------------------------------------------------------------
function openAdminMenu() {
const box = document.getElementById("adminBox");
box.style.display = "block";
ws.send(JSON.stringify({ type: "admin_get_players" }));
}
// -------------------------------------------------------------
// WELTKARTE + NAVI (NEU) - rein clientseitig, Daten sind schon geladen
// -------------------------------------------------------------
function openWorldMap() {
const map = maps[player.world];
if (!map || !map.tiles) return;
const box = document.getElementById("worldMapBox");
const canvas = document.getElementById("worldMapCanvas");
box.style.display = "flex";
const cols = map.tiles[0].length;
const rows = map.tiles.length;
const scale = Math.min(4, Math.floor(700 / Math.max(cols, rows)));
const px = Math.max(1, scale);
canvas.width = cols * px;
canvas.height = rows * px;
const ctx = canvas.getContext("2d");
// Tiles als kleines "Foto" zeichnen
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const tile = tileConfig[map.tiles[y][x]];
ctx.fillStyle = tile ? tile.color : "#000";
ctx.fillRect(x * px, y * px, px, px);
}
}
// POIs sammeln und einzeichnen
const poiGroups = [
{ list: shops || [], color: "#f1c40f", icon: "🛒", label: "Shop" },
{ list: garages || [], color: "#2ecc71", icon: "🚗", label: "Garage" },
{ list: gasStations || [], color: "#e67e22", icon: "⛽", label: "Tankstelle" },
{ list: repairShops || [], color: "#95a5a6", icon: "🔧", label: "Werkstatt" },
{ list: jobcenters || [], color: "#9b59b6", icon: "💼", label: "Jobcenter" },
{ list: houses || [], color: "#8b5a2b", icon: "🏠", label: "Haus" },
{ list: taxiStands || [], color: "#f5d90a", icon: "🚕", label: "Taxi-Stand" },
{ list: hospitals || [], color: "#e74c3c", icon: "🏥", label: "Krankenhaus" },
{ list: prisons || [], color: "#555555", icon: "🔒", label: "Gefängnis" },
{ list: impoundLots || [], color: "#b8860b", icon: "🚛", label: "Abschlepphof" },
{ list: fireStations || [], color: "#c0392b", icon: "🚒", label: "Feuerwache" },
{ list: harvestSpots || [], color: "#2ecc71", icon: "🌿", label: "Anbaustelle" },
{ list: processSpots || [], color: "#8e44ad", icon: "⚗️", label: "Labor" },
{ list: dealerSpots || [], color: "#e67e22", icon: "💰", label: "Verkaufsort" },
{ list: clothingShops || [], color: "#e84393", icon: "👕", label: "Kleidungsladen" },
{ list: trailerShops || [], color: "#8e6a3d", icon: "🚛", label: "Anhänger-Shop" },
{ list: highwayLinks || [], color: "#f0c419", icon: "🛣️", label: "Autobahn-Anschluss" },
{ list: blackMarketSpots || [], color: "#5a1a4a", icon: "🕶️", label: "Hehler" },
{ list: insuranceOffices || [], color: "#0984e3", icon: "🛡️", label: "Versicherung" },
{ list: plateOffices || [], color: "#636e72", icon: "🔖", label: "Zulassungsstelle" }
];
const markers = [];
poiGroups.forEach(group => {
group.list.forEach(poi => {
const mx = poi.x * px + px / 2;
const my = poi.y * px + px / 2;
ctx.fillStyle = group.color;
ctx.beginPath();
ctx.arc(mx, my, Math.max(3, px * 0.4), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 1;
ctx.stroke();
markers.push({ x: mx, y: my, worldX: poi.x * 32 + 16, worldY: poi.y * 32 + 16, label: `${group.icon} ${poi.name || group.label}` });
});
});
// eigene Position markieren
const myX = (player.x / 32) * px;
const myY = (player.y / 32) * px;
ctx.fillStyle = player.color || "#f1c40f";
ctx.beginPath();
ctx.arc(myX, myY, Math.max(4, px * 0.5), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.stroke();
// Legende bauen
const legend = document.getElementById("worldMapLegend");
legend.innerHTML = "<strong>Legende (klicken = Navi-Ziel setzen)</strong><br>";
poiGroups.forEach(group => {
if (group.list.length === 0) return;
legend.innerHTML += `<span style="color:${group.color};">●</span> ${group.icon} ${group.label} (${group.list.length})<br>`;
});
// Klick auf die Karte -> nächstgelegenen Marker als Navi-Ziel setzen
canvas.onclick = (e) => {
const rect = canvas.getBoundingClientRect();
const clickX = (e.clientX - rect.left) * (canvas.width / rect.width);
const clickY = (e.clientY - rect.top) * (canvas.height / rect.height);
let closest = null;
let closestDist = Infinity;
markers.forEach(m => {
const d = Math.hypot(m.x - clickX, m.y - clickY);
if (d < closestDist) { closestDist = d; closest = m; }
});
if (closest && closestDist < 20) {
naviTarget = { x: closest.worldX, y: closest.worldY, world: player.world, label: closest.label };
updateNaviDisplay();
addChatMessage(`🧭 Navigation gestartet: ${closest.label}`, "system");
closeWorldMap();
}
};
}
function closeWorldMap() {
document.getElementById("worldMapBox").style.display = "none";
}
function updateNaviDisplay() {
const el = document.getElementById("naviDisplay");
if (!el) return;
if (!naviTarget || naviTarget.world !== player.world) {
el.style.display = "none";
return;
}
const dx = naviTarget.x - player.x;
const dy = naviTarget.y - player.y;
const dist = Math.hypot(dx, dy);
if (dist < 50) {
addChatMessage(`🧭 Ziel erreicht: ${naviTarget.label}`, "system");
naviTarget = null;
el.style.display = "none";
return;
}
const angle = Math.atan2(dy, dx);
el.style.display = "flex";
document.getElementById("naviArrow").style.transform = `rotate(${angle}rad)`;
document.getElementById("naviLabel").textContent = `${naviTarget.label} - ${Math.round(dist)}m`;
}
function closeAdminMenu() {
document.getElementById("adminBox").style.display = "none";
}
// -------------------------------------------------------------
// TAXI (NEU)
// -------------------------------------------------------------
// -------------------------------------------------------------
// BEFEHLS-FENSTER: alle Befehle als Buttons + Eingabefeld (NEU)
// -------------------------------------------------------------
const COMMAND_CATEGORIES = [
{
cat: "Allgemein",
items: [
{ label: "Weltkarte öffnen", cmd: "map" },
{ label: "Debug-Overlay an/aus", cmd: "debug" },
{ label: "Navigation abbrechen", cmd: "navi cancel" }
]
},
{
cat: "Autos & Aktionen",
items: [
{ label: "Autoschlüssel geben", cmd: "key {0}", args: ["Name"] },
{ label: "Autoschlüssel entziehen", cmd: "key remove {0}", args: ["Name"] },
{ label: "Hausschlüssel geben", cmd: "housekey {0}", args: ["Name"] },
{ label: "Hausschlüssel entziehen", cmd: "housekey remove {0}", args: ["Name"] },
{ label: "Raubüberfall", cmd: "rob" },
{ label: "Auto kurzschließen", cmd: "hotwire" },
{ label: "Verhaften (Polizei)", cmd: "arrest {0}", args: ["Name"] },
{ label: "Sicherheitszone-Status anzeigen", cmd: "pvp" },
{ label: "Angreifen", cmd: "attack" }
]
},
{
cat: "Bande",
items: [
{ label: "Bande gründen (5000$)", cmd: "gang create {0} {1}", args: ["Name", "Tag"] },
{ label: "Einladen", cmd: "gang invite {0}", args: ["Name"] },
{ label: "Beitreten", cmd: "gang accept {0}", args: ["Bandenname"] },
{ label: "Kicken", cmd: "gang kick {0}", args: ["Name"] },
{ label: "Verlassen", cmd: "gang leave" },
{ label: "Einzahlen", cmd: "gang deposit {0}", args: ["Betrag"] },
{ label: "Auszahlen", cmd: "gang withdraw {0}", args: ["Betrag"] },
{ label: "Info", cmd: "gang info" }
]
},
{
cat: "Jobs",
items: [
{ label: "In geschützten Job einladen", cmd: "job invite {0}", args: ["Name"] }
]
},
{
cat: "Abschleppen",
items: [
{ label: "Fahrzeug anhängen", cmd: "tow hook" },
{ label: "Abliefern (+50$)", cmd: "tow dropoff" },
{ label: "Loslassen", cmd: "tow release" }
]
},
{
cat: "Shop & Tankstelle",
items: [
{ label: "Shop kaufen", cmd: "shop buy" },
{ label: "Shop freigeben", cmd: "shop unclaim" },
{ label: "Shop-Besitzer anzeigen", cmd: "shop info" },
{ label: "Tankstelle kaufen", cmd: "station buy" },
{ label: "Tankstelle freigeben", cmd: "station unclaim" },
{ label: "Tankstellen-Besitzer anzeigen", cmd: "station info" }
]
},
{
cat: "Versicherung",
items: [
{ label: "Auto versichern", cmd: "car insure" },
{ label: "Versicherung kündigen", cmd: "car uninsure" }
]
},
{
cat: "Tuning",
items: [
{ label: "Tuning-Menü öffnen (an der Werkstatt)", cmd: "tune" }
]
},
{
cat: "Anhänger",
items: [
{ label: "Anhänger kaufen", cmd: "trailer buy" },
{ label: "Anhänger ankuppeln", cmd: "trailer hitch" },
{ label: "Anhänger abkuppeln", cmd: "trailer unhitch" },
{ label: "Auto auf Anhänger laden", cmd: "trailer load" },
{ label: "Auto vom Anhänger holen", cmd: "trailer unload" }
]
},
{
cat: "Nummernschild",
items: [
{ label: "Nummernschild setzen", cmd: "plate {0}", args: ["Text"] }
]
},
{
cat: "Immobilien-Vermietung",
items: [
{ label: "Haus zur Miete anbieten", cmd: "house setrent {0}", args: ["Betrag"] },
{ label: "Mietangebot zurückziehen", cmd: "house unrent" },
{ label: "Haus mieten", cmd: "house rent" },
{ label: "Mieter kündigen", cmd: "house evict" },
{ label: "Selbst ausziehen", cmd: "house moveout" }
]
},
{
cat: "Kopfgeld",
items: [
{ label: "Kopfgeld aussetzen", cmd: "bounty {0} {1}", args: ["Name", "Betrag"] },
{ label: "Kopfgeld anzeigen", cmd: "bounty info {0}", args: ["Name"] }
]
},
{
cat: "Handschellen (Polizei)",
items: [
{ label: "Handschellen anlegen", cmd: "cuff {0}", args: ["Name"] },
{ label: "Handschellen abnehmen", cmd: "uncuff {0}", args: ["Name"] },
{ label: "Ins Auto setzen", cmd: "putin {0}", args: ["Name"] },
{ label: "Aus Auto holen", cmd: "takeout {0}", args: ["Name"] },
{ label: "Am Gefängnis abliefern", cmd: "jaildropoff {0}", args: ["Name"] }
]
},
{
cat: "Job-Funk",
items: [
{ label: "Funkspruch senden", cmd: "funk {0}", args: ["Nachricht"] }
]
},
{
cat: "Handy",
items: [
{ label: "Handy öffnen", cmd: "phone" },
{ label: "Kontakt anfragen", cmd: "phone add {0}", args: ["Name"] },
{ label: "Anfrage annehmen", cmd: "phone accept {0}", args: ["Name"] },
{ label: "Kontakt entfernen", cmd: "phone remove {0}", args: ["Name"] },
{ label: "SMS senden", cmd: "sms {0} {1}", args: ["Name", "Nachricht"] },
{ label: "Anrufen", cmd: "call {0}", args: ["Name"] }
]
},
{
cat: "Einsatz-Tablet",
items: [
{ label: "Tablet öffnen", cmd: "tablet" }
]
},
{
cat: "Support",
items: [
{ label: "Tickets öffnen", cmd: "ticket" }
]
},
{
cat: "Controller",
items: [
{ label: "Controller-Einstellungen öffnen", cmd: "controller" }
]
},
{
cat: "Börse",
items: [
{ label: "Börse öffnen", cmd: "börse" }
]
},
{
cat: "Schwarzmarkt",
items: [
{ label: "Gestohlenes Auto beim Hehler verkaufen", cmd: "verkaufen" }
]
},
{
cat: "Leitstelle",
items: [
{ label: "Leitstelle öffnen", cmd: "leitstelle" }
]
},
{
cat: "Feuerwehr",
items: [
{ label: "Nächstes Feuer löschen", cmd: "fire extinguish" }
]
},
{
cat: "Tore",
items: [
{ label: "Tor beanspruchen", cmd: "gate claim" },
{ label: "Tor freigeben", cmd: "gate unclaim" },
{ label: "Tor-Info anzeigen", cmd: "gate info" },
{ label: "Schlüssel vergeben", cmd: "gatekey {0}", args: ["Name"] },
{ label: "Schlüssel entziehen", cmd: "gatekey remove {0}", args: ["Name"] }
]
}
];
const COMMAND_CATEGORIES_ADMIN = [
{
cat: "Admin",
items: [
{ label: "Admin-Menü öffnen", cmd: "admin" },
{ label: "Neustart ankündigen", cmd: "restart {0}", args: ["Sekunden (leer=60)"] },
{ label: "Neustart abbrechen", cmd: "restart cancel" },
{ label: "Dienstmodus an/aus", cmd: "admin duty" },
{ label: "Tor auf Job beschränken", cmd: "gate setjob {0}", args: ["Jobname ('none' zum Entfernen)"] }
]
}
];
function openCommandsWindow() {
const box = document.getElementById("commandsBox");
const list = document.getElementById("cmdList");
list.innerHTML = "";
const categories = player.isAdmin
? [...COMMAND_CATEGORIES, ...COMMAND_CATEGORIES_ADMIN]
: COMMAND_CATEGORIES;
categories.forEach((group, gi) => {
const catEl = document.createElement("div");
catEl.className = "cmd-cat";
catEl.textContent = group.cat;
list.appendChild(catEl);
group.items.forEach((item, ii) => {
const row = document.createElement("div");
row.className = "cmd-row";
let inputsHtml = "";
(item.args || []).forEach((placeholder, ai) => {
inputsHtml += `<input type="text" id="cmdArg_${gi}_${ii}_${ai}" placeholder="${escapeHtmlAdmin(placeholder)}">`;
});
row.innerHTML = `
<span class="cmd-label">${escapeHtmlAdmin(item.label)}</span>
${inputsHtml}
<button data-gi="${gi}" data-ii="${ii}">Ausführen</button>
`;
list.appendChild(row);
});
});
list.querySelectorAll("button[data-gi]").forEach(btn => {
btn.onclick = () => {
const gi = Number(btn.dataset.gi);
const ii = Number(btn.dataset.ii);
const categories2 = player.isAdmin ? [...COMMAND_CATEGORIES, ...COMMAND_CATEGORIES_ADMIN] : COMMAND_CATEGORIES;
const item = categories2[gi].items[ii];
let finalCmd = item.cmd;
(item.args || []).forEach((placeholder, ai) => {
const input = document.getElementById(`cmdArg_${gi}_${ii}_${ai}`);
const val = input ? input.value.trim() : "";
finalCmd = finalCmd.replace(`{${ai}}`, val);
});
finalCmd = finalCmd.trim().replace(/\s+/g, " ");
handleChatCommand("/" + finalCmd);
};
});
box.style.display = "flex";
}
function closeCommandsWindow() {
document.getElementById("commandsBox").style.display = "none";
}
function runFreeCommand() {
const input = document.getElementById("cmdFreeInput");
let text = input.value.trim();
if (!text) return;
if (!text.startsWith("/")) text = "/" + text;
handleChatCommand(text);
input.value = "";
}
function openJobMenu() {
if (!ws) return;
ws.send(JSON.stringify({ type: "job_menu_open" }));
document.getElementById("jobMenuBox").style.display = "flex";
}
function closeJobMenu() {
document.getElementById("jobMenuBox").style.display = "none";
}
// -------------------------------------------------------------
// FAHRZEUG-TUNING (NEU)
// -------------------------------------------------------------
const TUNE_STAT_LABELS = { speed: "🏎️ Motor (Höchstgeschw.)", accel: "⚡ Beschleunigung", brake: "🛑 Bremsen" };
function openTuneWindow(msg) {
const box = document.getElementById("tuneBox");
const body = document.getElementById("tuneBody");
body.innerHTML = "";
["speed", "accel", "brake"].forEach(stat => {
const level = msg.levels[stat];
const cost = msg.costs[stat];
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>${TUNE_STAT_LABELS[stat]} - Stufe ${level}/${msg.maxLevel}</span>
<button class="jmBtn" ${cost === null ? "disabled" : ""} data-stat="${stat}">
${cost === null ? "Maximal" : `Aufwerten (${cost}$)`}
</button>
`;
body.appendChild(row);
});
const paintRow = document.createElement("div");
paintRow.className = "job-menu-row";
paintRow.innerHTML = `
<span>🎨 Lackierung (${msg.paintCost}$)</span>
<input type="color" id="tunePaintColor" value="${msg.currentColor || "#c0392b"}" style="width:50px; height:30px; padding:0; border:none; cursor:pointer;">
<button class="jmBtn" id="tunePaintBtn">Lackieren</button>
`;
body.appendChild(paintRow);
body.querySelectorAll(".jmBtn[data-stat]").forEach(btn => {
btn.onclick = () => {
ws.send(JSON.stringify({ type: "tune_upgrade", stat: btn.dataset.stat }));
closeTuneWindow();
};
});
document.getElementById("tunePaintBtn").onclick = () => {
const color = document.getElementById("tunePaintColor").value;
ws.send(JSON.stringify({ type: "tune_paint", color }));
closeTuneWindow();
};
box.style.display = "flex";
}
function closeTuneWindow() {
document.getElementById("tuneBox").style.display = "none";
}
function renderJobMenu(msg) {
const body = document.getElementById("jobMenuBody");
const title = document.getElementById("jobMenuTitle");
body.innerHTML = "";
if (!msg.jobType) {
title.textContent = "📋 Job-Menü";
body.innerHTML = `<p style="color:#999;">Du hast aktuell keinen Job mit Menü-Aktionen. (Taxi, Polizei, Sanitäter, Abschlepper haben eins)</p>`;
return;
}
title.textContent = `📋 Job-Menü - ${msg.jobName || msg.jobType}`;
if (msg.jobType === "taxi") {
if (!msg.requests || msg.requests.length === 0) {
body.innerHTML = `<p style="color:#999;">Aktuell wartet niemand auf ein Taxi.</p>`;
return;
}
msg.requests.forEach(r => {
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(r.username)} (${escapeHtmlAdmin(r.world)})</span>
<button class="jmBtn" data-action="taxi_pickup" data-id="${r.playerId}">Abholen</button>
`;
body.appendChild(row);
});
}
if (msg.jobType === "police") {
body.innerHTML = "";
const wantedTitle = document.createElement("h4");
wantedTitle.textContent = "Gesucht";
wantedTitle.style.cssText = "margin:6px 0 4px; color:#e06c6c; font-size:13px;";
body.appendChild(wantedTitle);
if (!msg.wanted || msg.wanted.length === 0) {
body.innerHTML += `<p style="color:#999; font-size:12px;">Aktuell ist niemand gesucht.</p>`;
} else {
msg.wanted.forEach(w => {
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(w.username)} ${"⭐".repeat(w.wantedLevel)} (${escapeHtmlAdmin(w.world)})</span>
<span>
<button class="jmBtn" data-action="arrest" data-username="${escapeHtmlAdmin(w.username)}">Sofort verhaften</button>
<button class="jmBtn" data-action="cuff" data-username="${escapeHtmlAdmin(w.username)}">Fesseln</button>
</span>
`;
body.appendChild(row);
});
}
const cuffedTitle = document.createElement("h4");
cuffedTitle.textContent = "Gefesselte Personen";
cuffedTitle.style.cssText = "margin:14px 0 4px; color:#f5d90a; font-size:13px;";
body.appendChild(cuffedTitle);
if (!msg.cuffed || msg.cuffed.length === 0) {
body.innerHTML += `<p style="color:#999; font-size:12px;">Aktuell niemand gefesselt.</p>`;
} else {
msg.cuffed.forEach(c => {
const row = document.createElement("div");
row.className = "job-menu-row";
let buttons = `<button class="jmBtn" data-action="uncuff" data-username="${escapeHtmlAdmin(c.username)}">Losbinden</button>`;
if (c.inMyCar) {
buttons += `<button class="jmBtn" data-action="takeout" data-username="${escapeHtmlAdmin(c.username)}">Aus Auto holen</button>`;
buttons += `<button class="jmBtn" data-action="jaildropoff" data-username="${escapeHtmlAdmin(c.username)}">Am Gefängnis abliefern</button>`;
} else if (!c.inAnyCar && msg.isDriving) {
buttons += `<button class="jmBtn" data-action="putin" data-username="${escapeHtmlAdmin(c.username)}">Ins Auto setzen</button>`;
}
row.innerHTML = `
<span>${escapeHtmlAdmin(c.username)} 🔗 (${escapeHtmlAdmin(c.world)})${c.inMyCar ? " - bei dir im Auto" : c.inAnyCar ? " - in einem Auto" : ""}</span>
<span>${buttons}</span>
`;
body.appendChild(row);
});
}
}
if (msg.jobType === "medic") {
if (!msg.injured || msg.injured.length === 0) {
body.innerHTML = `<p style="color:#999;">Aktuell ist niemand verletzt.</p>`;
return;
}
msg.injured.forEach(i => {
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(i.username)} - ${i.health}% Leben (${escapeHtmlAdmin(i.world)})</span>
<button class="jmBtn" data-action="heal" data-id="${i.playerId}">Heilen</button>
`;
body.appendChild(row);
});
}
if (msg.jobType === "mechanic") {
if (!msg.damaged || msg.damaged.length === 0) {
body.innerHTML = `<p style="color:#999;">Kein beschädigtes Fahrzeug in der Nähe.</p>`;
return;
}
msg.damaged.forEach(d => {
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>${escapeHtmlAdmin(d.model)} (${escapeHtmlAdmin(d.ownerName)}) - ${d.health}% Zustand</span>
<button class="jmBtn" data-action="mechanic_repair" data-id="${d.carId}">Reparieren</button>
`;
body.appendChild(row);
});
}
if (msg.jobType === "tow") {
if (!msg.isDriving) {
body.innerHTML = `<p style="color:#999;">Du musst im Abschleppwagen sitzen, um diese Aktionen zu nutzen.</p>`;
return;
}
if (msg.isTowing) {
body.innerHTML = `
<div class="job-menu-row"><span>Fahrzeug angehängt</span></div>
<button class="jmBtn wide" data-action="tow_dropoff">Am Abschlepphof abliefern (+50$)</button>
<button class="jmBtn wide secondary" data-action="tow_release">Loslassen</button>
`;
} else {
body.innerHTML = `
<div class="job-menu-row"><span>Kein Fahrzeug angehängt</span></div>
<button class="jmBtn wide" data-action="tow_hook">Freistehendes Fahrzeug anhängen</button>
`;
}
}
if (msg.jobType === "fire") {
if (!msg.fires || msg.fires.length === 0) {
body.innerHTML = `<p style="color:#999;">Aktuell brennt nichts.</p>`;
return;
}
msg.fires.forEach(f => {
const row = document.createElement("div");
row.className = "job-menu-row";
row.innerHTML = `
<span>🔥 Feuer bei (${f.x}, ${f.y}) - ${f.intensity}%</span>
<button class="jmBtn" data-action="extinguish" data-id="${f.id}">Löschen</button>
`;
body.appendChild(row);
});
}
body.querySelectorAll(".jmBtn").forEach(btn => {
btn.onclick = () => {
const action = btn.dataset.action;
if (action === "taxi_pickup") {
ws.send(JSON.stringify({ type: "taxi_pickup", playerId: Number(btn.dataset.id) }));
} else if (action === "arrest") {
ws.send(JSON.stringify({ type: "arrest_player", username: btn.dataset.username }));
} else if (action === "heal") {
ws.send(JSON.stringify({ type: "medic_heal", targetId: Number(btn.dataset.id) }));
} else if (action === "mechanic_repair") {
ws.send(JSON.stringify({ type: "mechanic_repair", carId: Number(btn.dataset.id) }));
} else if (action === "cuff") {
ws.send(JSON.stringify({ type: "police_cuff", username: btn.dataset.username }));
} else if (action === "uncuff") {
ws.send(JSON.stringify({ type: "police_uncuff", username: btn.dataset.username }));
} else if (action === "putin") {
ws.send(JSON.stringify({ type: "police_put_in_car", username: btn.dataset.username }));
} else if (action === "takeout") {
ws.send(JSON.stringify({ type: "police_take_out_of_car", username: btn.dataset.username }));
} else if (action === "jaildropoff") {
ws.send(JSON.stringify({ type: "police_jail_dropoff", username: btn.dataset.username }));
} else if (action === "tow_hook") {
ws.send(JSON.stringify({ type: "tow_hook" }));
} else if (action === "tow_dropoff") {
ws.send(JSON.stringify({ type: "tow_dropoff" }));
} else if (action === "tow_release") {
ws.send(JSON.stringify({ type: "tow_release" }));
} else if (action === "extinguish") {
ws.send(JSON.stringify({ type: "fire_extinguish", fireId: Number(btn.dataset.id) }));
}
closeJobMenu();
};
});
}
function openTaxiWindow(requests) {
const box = document.getElementById("taxiBox");
const list = document.getElementById("taxiRequestList");
list.innerHTML = "";
if (!requests || requests.length === 0) {
list.innerHTML = `<p style="color:#888;">Aktuell wartet niemand auf ein Taxi.</p>`;
} else {
requests.forEach(r => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.alignItems = "center";
div.style.padding = "6px 0";
div.innerHTML = `
<span>${escapeHtmlAdmin(r.username)} (${escapeHtmlAdmin(r.world)})</span>
<button class="taxiPickupBtn" data-id="${r.playerId}">Abholen</button>
`;
list.appendChild(div);
});
}
if (!list.dataset.bound) {
list.addEventListener("click", e => {
const btn = e.target.closest(".taxiPickupBtn");
if (btn) {
ws.send(JSON.stringify({ type: "taxi_pickup", playerId: Number(btn.dataset.id) }));
closeTaxiWindow();
}
});
list.dataset.bound = "true";
}
box.style.display = "block";
}
function closeTaxiWindow() {
document.getElementById("taxiBox").style.display = "none";
}
function refreshAdminMenu() {
ws.send(JSON.stringify({ type: "admin_get_players" }));
}
function toggleAdminDuty() {
ws.send(JSON.stringify({ type: "admin_toggle_duty" }));
}
function triggerServerRestart() {
const secondsStr = prompt("Neustart in wie vielen Sekunden ankündigen?", "60");
if (secondsStr === null) return;
const seconds = Number(secondsStr) || 60;
if (!confirm(`Server-Neustart in ${seconds}s ankündigen? Alle Spieler werden anschließend zur Startseite geschickt.`)) {
return;
}
ws.send(JSON.stringify({ type: "admin_restart_server", seconds }));
}
function cancelServerRestart() {
ws.send(JSON.stringify({ type: "admin_restart_server", cancel: true }));
}
function renderAdminPlayerList(players) {
const list = document.getElementById("adminPlayerList");
list.innerHTML = "";
players.forEach(pl => {
const div = document.createElement("div");
div.className = "admin-player-row";
div.innerHTML = `
<div class="admin-player-head">
<strong>${escapeHtmlAdmin(pl.username)}</strong>
<span class="admin-player-meta">#${pl.id}${escapeHtmlAdmin(pl.world)} (${pl.x}, ${pl.y})</span>
</div>
<div class="admin-player-actions">
<input type="number" class="moneyInput" data-id="${pl.id}" value="${pl.money}" style="width:90px;">
<button class="setMoneyBtn" data-id="${pl.id}">Setzen</button>
<button class="teleportBtn" data-id="${pl.id}">Teleport zu</button>
<button class="lockBtn danger" data-id="${pl.id}">Sperren</button>
</div>
`;
list.appendChild(div);
});
if (!list.dataset.bound) {
list.addEventListener("click", e => {
const setBtn = e.target.closest(".setMoneyBtn");
const tpBtn = e.target.closest(".teleportBtn");
const lockBtn = e.target.closest(".lockBtn");
if (setBtn) {
const id = setBtn.dataset.id;
const input = list.querySelector(`.moneyInput[data-id="${id}"]`);
const amount = Number(input.value);
if (!isNaN(amount)) {
ws.send(JSON.stringify({ type: "admin_set_money", targetId: Number(id), amount }));
}
}
if (tpBtn) {
ws.send(JSON.stringify({ type: "admin_teleport_to", targetId: Number(tpBtn.dataset.id) }));
closeAdminMenu();
}
if (lockBtn) {
if (confirm("Diesen Spieler wirklich sperren? Er wird sofort getrennt.")) {
ws.send(JSON.stringify({ type: "admin_lock_player", targetId: Number(lockBtn.dataset.id) }));
setTimeout(refreshAdminMenu, 300);
}
}
});
list.dataset.bound = "true";
}
}
function escapeHtmlAdmin(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function updateOnlineCount(count) {
const el = document.getElementById("onlineCount");
if (el) el.textContent = `👥 ${count} online`;
}
function updateWantedDisplay() {
const el = document.getElementById("wantedDisplay");
if (!el) return;
if (!player.wantedLevel) {
el.textContent = "";
return;
}
el.textContent = "⭐".repeat(player.wantedLevel);
}
function updatePvpDisplay() {
const el = document.getElementById("pvpDisplay");
if (!el) return;
el.textContent = player.inSafeZone ? "🛡️ Sicherheitszone" : "⚔️ PvP aktiv";
el.style.color = player.inSafeZone ? "#6fbf73" : "#e06c6c";
}
function updateLevelDisplay() {
const el = document.getElementById("levelDisplay");
if (!el) return;
const xpInLevel = (player.xp || 0) % 500;
el.textContent = `⭐ Lvl ${player.level || 1} (${xpInLevel}/500 XP)`;
}
function formatEventTimeLeft(endsAt) {
const diff = endsAt - Date.now();
if (diff <= 0) return "";
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
return h > 0 ? `${h}h ${m}min` : `${m}min`;
}
function updateEventDisplay() {
const el = document.getElementById("eventBanner");
if (!el) return;
const parts = [];
if (activeEvents.doubleXp && activeEvents.doubleXp.active) {
parts.push(`⭐ Doppel-XP (${formatEventTimeLeft(activeEvents.doubleXp.endsAt)})`);
}
if (activeEvents.discount && activeEvents.discount.active) {
parts.push(`💰 ${activeEvents.discount.percent}% Rabatt (${formatEventTimeLeft(activeEvents.discount.endsAt)})`);
}
if (parts.length === 0) {
el.style.display = "none";
} else {
el.style.display = "block";
el.textContent = parts.join(" • ");
}
}
// Restzeit-Anzeige jede Minute nachschärfen, auch ohne neue Nachricht vom Server
setInterval(updateEventDisplay, 30000);
function updateDutyDisplay() {
const el = document.getElementById("dutyDisplay");
if (!el) return;
el.textContent = player.adminDuty ? "🛡️ Im Dienst" : "";
}
function updateJailDisplay() {
const box = document.getElementById("jailBox");
if (!box) return;
if (!jailedUntil || jailedUntil <= Date.now()) {
box.style.display = "none";
return;
}
const remain = Math.max(0, Math.ceil((jailedUntil - Date.now()) / 1000));
document.getElementById("jailCountdown").textContent = remain + "s";
box.style.display = "flex";
}
setInterval(updateJailDisplay, 1000);
let deathScreenTimeout = null;
function showDeathScreen(killerName) {
const box = document.getElementById("deathScreen");
const text = document.getElementById("deathScreenText");
text.textContent = killerName ? `Du wurdest von ${killerName} getötet.` : "Du bist gestorben.";
box.style.display = "flex";
if (deathScreenTimeout) clearTimeout(deathScreenTimeout);
deathScreenTimeout = setTimeout(() => {
box.style.display = "none";
}, 3200);
}
// -------------------------------------------------------------
// UHR (NEU)
// -------------------------------------------------------------
const DAY_LENGTH_MINUTES = 24; // muss zum Server passen
function updateClockDisplay() {
const el = document.getElementById("clockDisplay");
if (!el) return;
const h = Math.floor(gameHour) % 24;
const m = Math.floor((gameHour % 1) * 60);
const icon = (gameHour >= 6 && gameHour < 19) ? "🌞" : "🌙";
const weatherIcon = weather === "rain" ? " 🌧️" : "";
el.textContent = `${icon} ${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}${weatherIcon}`;
}
// Uhrzeit zwischen den Server-Syncs (alle 5s) sanft weiterlaufen lassen
setInterval(() => {
gameHour = (gameHour + 24 / (DAY_LENGTH_MINUTES * 60)) % 24;
updateClockDisplay();
}, 1000);
async function loadVersionDisplay() {
try {
const res = await fetch("/api/status");
const data = await res.json();
const el = document.getElementById("versionDisplay");
if (el && data.version) el.textContent = "v" + data.version;
} catch {}
}
loadVersionDisplay();
function useItem(itemId) {
ws.send(JSON.stringify({
type: "use_item",
itemId
}));
}
function buyItem(itemId) {
ws.send(JSON.stringify({
type: "shop_buy",
itemId
}));
}
function setShopItemPrice(itemId) {
const input = document.getElementById(`shopPriceInput_${itemId}`);
const price = Number(input.value);
if (isNaN(price) || price < 1) {
addChatMessage("Ungültiger Preis.", "system");
return;
}
ws.send(JSON.stringify({
type: "shop_set_price",
itemId,
price
}));
}
// -------------------------------------------------------------
// CAMERA (mit Zentrierung bei kleinen Maps, folgt Auto beim Fahren)
// -------------------------------------------------------------
function getCamera() {
const map = maps[player.world];
if (!map || !map.tiles || map.tiles.length === 0) {
return { x: 0, y: 0 };
}
let focusX = player.x;
let focusY = player.y;
if (drivingCarId) {
const c = cars.find(c => c.id === drivingCarId);
if (c) {
focusX = c.x;
focusY = c.y;
}
}
const mapWidth = map.tiles[0].length * 32;
const mapHeight = map.tiles.length * 32;
let camX = focusX - canvas.width / 2;
let camY = focusY - canvas.height / 2;
if (mapWidth < canvas.width) {
camX = -(canvas.width / 2 - mapWidth / 2);
} else {
if (camX < 0) camX = 0;
if (camX > mapWidth - canvas.width)
camX = mapWidth - canvas.width;
}
if (mapHeight < canvas.height) {
camY = -(canvas.height / 2 - mapHeight / 2);
} else {
if (camY < 0) camY = 0;
if (camY > mapHeight - canvas.height)
camY = mapHeight - canvas.height;
}
return { x: Math.round(camX), y: Math.round(camY) };
}
// -------------------------------------------------------------
// MOVEMENT + Collision
// -------------------------------------------------------------
function isAnyOverlayOpen() {
const ids = [
"commandsBox", "phoneBox", "tabletBox", "clothingBox", "wardrobeBox",
"shopWindow", "garageBox", "jobBox", "trunkBox", "houseBox",
"adminBox", "taxiBox", "worldMapBox", "jobMenuBox", "tuneBox", "trailerShopBox", "ticketBox", "gamepadSettingsBox", "stockBox", "leitstelleBox"
];
return ids.some(id => {
const el = document.getElementById(id);
return el && el.style.display !== "none" && el.style.display !== "";
});
}
document.addEventListener("keydown", e => {
keys[e.key] = true;
if (!ws) return;
if (isAnyOverlayOpen()) return; // Fenster offen (z.B. /commands) - alle Tastenkürzel gesperrt
if (e.key.toLowerCase() === "e") {
if (drivingCarId) {
// Direkt einparken, falls vor einer eigenen Garage - sonst normal aussteigen/tanken/reparieren
ws.send(JSON.stringify({ type: "car_exit_or_park" }));
} else if (passengerCarId) {
// Als Mitfahrer aussteigen
ws.send(JSON.stringify({ type: "car_exit_passenger" }));
} else {
// Erst versuchen einzusteigen (Fahrer oder Mitfahrer), sonst normale Interaktion
ws.send(JSON.stringify({ type: "car_enter" }));
ws.send(JSON.stringify({
type: "interact",
x: player.x,
y: player.y,
world: player.world
}));
}
}
if (e.key.toLowerCase() === "j") {
openJobMenu();
}
if (e.key.toLowerCase() === "v") {
toggleMic();
}
if (e.key.toLowerCase() === "p") {
openPhone();
}
if (e.key.toLowerCase() === "o") {
openTablet();
}
if (e.key.toLowerCase() === "k") {
ws.send(JSON.stringify({ type: "trailer_toggle_hitch" }));
}
// Blinker/Licht/Warnblinker nur während der Fahrt
if (drivingCarId) {
if (e.key === "1") ws.send(JSON.stringify({ type: "car_toggle", what: "left" }));
if (e.key === "2") ws.send(JSON.stringify({ type: "car_toggle", what: "right" }));
if (e.key === "3") ws.send(JSON.stringify({ type: "car_toggle", what: "headlights" }));
if (e.key === "4") ws.send(JSON.stringify({ type: "car_toggle", what: "hazard" }));
if (e.key === "5") ws.send(JSON.stringify({ type: "car_toggle", what: "emergency" }));
if (e.key.toLowerCase() === "r") cycleRadio();
}
// Q-Taste: Tor öffnen/schließen, falls eins in der Nähe ist - geht zu Fuß und im Auto
if (e.key.toLowerCase() === "q") {
ws.send(JSON.stringify({ type: "gate_toggle_nearby" }));
}
// Tanken/Reparieren nur während der Fahrt
if (drivingCarId && e.key.toLowerCase() === "q") {
ws.send(JSON.stringify({ type: "car_use_station" }));
}
// Kofferraum öffnen (im Auto oder neben dem eigenen geparkten Auto)
if (e.key.toLowerCase() === "t") {
ws.send(JSON.stringify({ type: "trunk_open" }));
}
});
// Shop/Garage/Jobcenter/Kofferraum schließen (ESC)
document.addEventListener("keydown", e => {
if (e.key === "Escape") {
shopWindow.style.display = "none";
const garageBox = document.getElementById("garageBox");
if (garageBox) garageBox.style.display = "none";
const jobBox = document.getElementById("jobBox");
if (jobBox) jobBox.style.display = "none";
const trunkBox = document.getElementById("trunkBox");
if (trunkBox) trunkBox.style.display = "none";
const houseBox = document.getElementById("houseBox");
if (houseBox) houseBox.style.display = "none";
const adminBox = document.getElementById("adminBox");
if (adminBox) adminBox.style.display = "none";
const taxiBox = document.getElementById("taxiBox");
if (taxiBox) taxiBox.style.display = "none";
const worldMapBox = document.getElementById("worldMapBox");
if (worldMapBox) worldMapBox.style.display = "none";
const jobMenuBox = document.getElementById("jobMenuBox");
if (jobMenuBox) jobMenuBox.style.display = "none";
const commandsBox = document.getElementById("commandsBox");
if (commandsBox) commandsBox.style.display = "none";
const tuneBox = document.getElementById("tuneBox");
if (tuneBox) tuneBox.style.display = "none";
const phoneBox = document.getElementById("phoneBox");
if (phoneBox) phoneBox.style.display = "none";
const clothingBox = document.getElementById("clothingBox");
if (clothingBox) clothingBox.style.display = "none";
const trailerShopBox = document.getElementById("trailerShopBox");
if (trailerShopBox) trailerShopBox.style.display = "none";
const wardrobeBox = document.getElementById("wardrobeBox");
if (wardrobeBox) wardrobeBox.style.display = "none";
const tabletBox = document.getElementById("tabletBox");
if (tabletBox) tabletBox.style.display = "none";
const ticketBox = document.getElementById("ticketBox");
if (ticketBox) ticketBox.style.display = "none";
const gamepadSettingsBox = document.getElementById("gamepadSettingsBox");
if (gamepadSettingsBox) gamepadSettingsBox.style.display = "none";
const stockBox = document.getElementById("stockBox");
if (stockBox) stockBox.style.display = "none";
const leitstelleBox = document.getElementById("leitstelleBox");
if (leitstelleBox && leitstelleBox.style.display !== "none") closeLeitstelle();
}
});
document.addEventListener("keyup", e => {
keys[e.key] = false;
});
function canMoveTo(nx, ny) {
const map = maps[player.world];
if (!map || !map.tiles) return true;
const tileX = Math.floor(nx / 32);
const tileY = Math.floor(ny / 32);
// KARTENRAND: außerhalb der Map ist grundsätzlich gesperrt
if (tileY < 0 || tileY >= map.tiles.length || tileX < 0 || tileX >= map.tiles[0].length) {
return false;
}
// TILE COLLISION
const tileId = map.tiles[tileY]?.[tileX];
const tile = tileConfig[tileId];
if (tile && tile.collision) return false;
// OBJECT COLLISION
if (map.objects) {
for (const obj of map.objects) {
const cfg = objectConfig[obj.type];
if (!cfg || !cfg.collision) continue;
if (obj.open) continue; // Tor/Tür ist offen -> keine Kollision
const ox = obj.x;
const oy = obj.y - (cfg.height - 32); // Objekt höher als Tile
const ow = cfg.width;
const oh = cfg.height;
// Spieler-Hitbox (30x30)
const px = nx;
const py = ny;
const pw = 30;
const ph = 30;
const hit =
px < ox + ow &&
px + pw > ox &&
py < oy + oh &&
py + ph > oy;
if (hit) return false;
}
}
return true;
}
function updateNeeds() {
player.hunger -= 0.002;
player.thirst -= 0.004;
if (player.hunger < 0) player.hunger = 0;
if (player.thirst < 0) player.thirst = 0;
if (player.hunger === 0 || player.thirst === 0) {
player.health -= 0.01;
if (player.health < 0) player.health = 0;
}
}
function updateStatusWindow() {
const status = document.getElementById("statusWindow");
status.innerHTML = `
Leben: ${Math.round(player.health)}<br>
Hunger: ${Math.round(player.hunger)}<br>
Durst: ${Math.round(player.thirst)}<br>
Geld: ${player.money}$
`;
}
// -------------------------------------------------------------
// MOVEMENT (Fuß ODER Auto)
// -------------------------------------------------------------
function updateMovement() {
// In Haft: keine Bewegung möglich
if (jailedUntil && jailedUntil > Date.now()) return;
// Mitfahrer: keine eigene Bewegung, Position kommt vom Server (ans Auto gekoppelt)
if (passengerCarId) return;
// Fahren: WASD steuert Gas/Bremse/Lenkung statt Laufen
if (drivingCarId) {
let throttle = 0;
let steer = 0;
if (keys["w"] || gamepadMove.w || touchMove.w) throttle = 1;
if (keys["s"] || gamepadMove.s || touchMove.s) throttle = -1;
if (keys["a"] || gamepadMove.a || touchMove.a) steer = -1;
if (keys["d"] || gamepadMove.d || touchMove.d) steer = 1;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "car_control", throttle, steer }));
}
return;
}
let moved = false;
let nx = player.x;
let ny = player.y;
if (keys["w"] || gamepadMove.w || touchMove.w) { ny -= 5; moved = true; }
if (keys["s"] || gamepadMove.s || touchMove.s) { ny += 5; moved = true; }
if (keys["a"] || gamepadMove.a || touchMove.a) { nx -= 5; moved = true; }
if (keys["d"] || gamepadMove.d || touchMove.d) { nx += 5; moved = true; }
if (canMoveTo(nx, ny)) {
player.x = nx;
player.y = ny;
if (ws && moved && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "move",
x: nx,
y: ny
}));
}
}
}
function renderInventoryWindow() {
const list = document.getElementById("inventoryList");
if (!list) return;
list.innerHTML = "";
(player.inventory || []).forEach(item => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.justifyContent = "space-between";
div.style.padding = "5px";
div.style.borderBottom = "1px solid #333";
const clothingMatch = String(item.id).match(/^clothing_(\d+)$/);
if (clothingMatch) {
const catalogItem = clothingCatalogCache[Number(clothingMatch[1])];
const label = catalogItem ? catalogItem.name : item.id;
div.innerHTML = `
<span>👕 ${escapeHtmlAdmin(label)} x${item.amount}</span>
<button class="wearItemBtn" data-id="${item.id}">Anziehen</button>
`;
} else {
div.innerHTML = `
<span>${item.name || item.id} x${item.amount}</span>
<button class="useItemBtn" data-id="${item.id}">Benutzen</button>
`;
}
list.appendChild(div);
});
document.querySelectorAll(".useItemBtn").forEach(btn => {
btn.onclick = () => {
useItem(btn.dataset.id);
};
});
document.querySelectorAll(".wearItemBtn").forEach(btn => {
btn.onclick = () => {
ws.send(JSON.stringify({ type: "wear_clothing", invItemId: btn.dataset.id }));
};
});
}
// -------------------------------------------------------------
// RENDERING
// -------------------------------------------------------------
function renderMap() {
const map = maps[player.world];
if (!map || !map.tiles) return;
const cam = getCamera();
// Nur den sichtbaren Ausschnitt zeichnen (wichtig bei großen Maps)
const startCol = Math.max(0, Math.floor(cam.x / 32));
const endCol = Math.min(map.tiles[0].length, startCol + Math.ceil(canvas.width / 32) + 1);
const startRow = Math.max(0, Math.floor(cam.y / 32));
const endRow = Math.min(map.tiles.length, startRow + Math.ceil(canvas.height / 32) + 1);
for (let y = startRow; y < endRow; y++) {
for (let x = startCol; x < endCol; x++) {
const tileId = map.tiles[y][x];
const tile = tileConfig[tileId];
if (!tile) continue;
const img = tileImageCache[tileId];
const rot = (map.tileRot && map.tileRot[y] && map.tileRot[y][x]) || 0;
if (img && img.complete && img.naturalWidth > 0) {
const px = x * 32 - cam.x;
const py = y * 32 - cam.y;
if (rot !== 0) {
ctx.save();
ctx.translate(px + 16, py + 16);
ctx.rotate(rot * Math.PI / 180);
ctx.drawImage(img, -16, -16, 32, 32);
ctx.restore();
} else {
ctx.drawImage(img, px, py, 32, 32);
}
continue;
}
ctx.fillStyle = tile.color;
ctx.fillRect(
x * 32 - cam.x,
y * 32 - cam.y,
32,
32
);
}
}
}
function renderDoors() {
const map = maps[player.world];
if (!map || !map.doors) return;
const cam = getCamera();
map.doors.forEach(d => {
ctx.fillStyle = "orange";
ctx.fillRect(
d.x - cam.x,
d.y - cam.y,
30,
30
);
});
}
function drawNameTag(username, id, px, py, gangTag, gangColor, title) {
const label = (gangTag ? `[${gangTag}] ` : "") + `${username || "???"} (#${id})`;
ctx.font = "12px Arial";
ctx.textAlign = "center";
// Titel (falls gesetzt): kleine goldene Zeile über dem Namen
if (title) {
ctx.font = "10px Arial";
ctx.fillStyle = "black";
ctx.fillText(title, px + 11, py - 16);
ctx.fillStyle = "#f5d90a";
ctx.fillText(title, px + 10, py - 17);
ctx.font = "12px Arial";
}
// Schatten für bessere Lesbarkeit auf jedem Untergrund
ctx.fillStyle = "black";
ctx.fillText(label, px + 11, py - 4);
ctx.fillStyle = gangColor || "white";
ctx.fillText(label, px + 10, py - 5);
ctx.textAlign = "left";
}
function drawPackageIcon(px, py) {
ctx.font = "14px Arial";
ctx.textAlign = "center";
ctx.fillText("📦", px + 10, py - 18);
ctx.textAlign = "left";
}
// -------------------------------------------------------------
// KLEIDUNGSLADEN
// -------------------------------------------------------------
function openClothingShop(catalog) {
renderClothingCatalog("shirt", catalog.shirt || []);
renderClothingCatalog("pants", catalog.pants || []);
renderClothingCatalog("shoes", catalog.shoes || []);
renderClothingCatalog("helmet", catalog.helmet || []);
document.getElementById("clothingBox").style.display = "flex";
}
function openTrailerShop(catalog) {
const container = document.getElementById("trailerShopCatalog");
container.innerHTML = "";
if (!catalog || catalog.length === 0) {
container.innerHTML = `<p style="color:#999; font-size:13px;">Aktuell ist kein Anhänger-Modell im Angebot.</p>`;
} else {
catalog.forEach(item => {
const row = document.createElement("div");
row.className = "clothing-catalog-row";
row.innerHTML = `
<span style="flex:1;">${escapeHtmlAdmin(item.model)}</span>
<span style="color:#f5d90a;">${item.price}$</span>
<button onclick="buyTrailerFromShop('${escapeHtmlAdmin(item.model)}')">Kaufen</button>
`;
container.appendChild(row);
});
}
document.getElementById("trailerShopBox").style.display = "flex";
}
function closeTrailerShop() {
document.getElementById("trailerShopBox").style.display = "none";
}
function buyTrailerFromShop(model) {
ws.send(JSON.stringify({ type: "trailer_shop_buy", model }));
}
function closeClothingShop() {
document.getElementById("clothingBox").style.display = "none";
}
function renderClothingCatalog(slot, items) {
const container = document.getElementById("clothingCatalog_" + slot);
if (!container) return;
container.innerHTML = "";
if (items.length === 0) {
container.innerHTML = `<div style="color:#666; font-size:12px; padding:6px 0;">Noch nichts im Angebot.</div>`;
return;
}
items.forEach(item => {
const row = document.createElement("div");
row.className = "clothing-catalog-row";
row.innerHTML = `
<span class="swatch-small" style="background:${item.color};"></span>
<span style="flex:1;">${escapeHtmlAdmin(item.name)}</span>
<button onclick="buyClothingItem(${item.id})">${item.price}$</button>
`;
container.appendChild(row);
});
}
function buyClothingItem(itemId) {
ws.send(JSON.stringify({ type: "buy_clothing_item", itemId }));
}
function unwearClothing(slot) {
ws.send(JSON.stringify({ type: "unwear_clothing", slot }));
}
const clothingImageCache = {};
function getClothingImage(dataUrl) {
if (!dataUrl) return null;
if (!clothingImageCache[dataUrl]) {
const img = new Image();
img.src = dataUrl;
clothingImageCache[dataUrl] = img;
}
return clothingImageCache[dataUrl];
}
function drawClothingBand(x, y, w, h, color, imageDataUrl) {
const img = imageDataUrl ? getClothingImage(imageDataUrl) : null;
if (img && img.complete && img.naturalWidth > 0) {
ctx.drawImage(img, x, y, w, h);
} else {
ctx.fillStyle = color;
ctx.fillRect(x, y, w, h);
}
}
function drawPlayerBody(x, y, shirtColor, pantsColor, shoesColor, skin, shirtImage, pantsImage, shoesImage, skinImage, helmetImage) {
// Figur besteht aus zwei gestapelten 20x20-Kästen:
// - oberer Kasten (y-20 bis y): Skin + Helm ("Kopf")
// - unterer Kasten (y bis y+20): Oberteil/Hose/Schuhe ("Körper")
// "y" bleibt weiterhin der ursprüngliche Ankerpunkt (Spielerposition/Kollision unverändert)
const headY = y - 20;
// Unterer Kasten: Kleidung in drei horizontalen Bändern
drawClothingBand(x, y, 20, 9, shirtColor || "#3498db", shirtImage);
drawClothingBand(x, y + 9, 20, 7, pantsColor || "#2c3e50", pantsImage);
drawClothingBand(x, y + 16, 20, 4, shoesColor || "#1a1a1a", shoesImage);
ctx.strokeStyle = "black";
ctx.strokeRect(x, y, 20, 20);
// Oberer Kasten: Skin, dann Helm (Helm sitzt über allem) - kein Rahmen mehr,
// damit der gemalte Skin nicht von einer schwarzen Umrandung eingerahmt wird
if (skinImage) {
const img = getClothingImage(skinImage);
if (img && img.complete && img.naturalWidth > 0) {
ctx.drawImage(img, x, headY, 20, 20);
}
} else if (skin && skin !== "none") {
ctx.font = "18px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(skin, x + 10, headY + 11);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
if (helmetImage) {
const img = getClothingImage(helmetImage);
if (img && img.complete && img.naturalWidth > 0) {
ctx.drawImage(img, x, headY, 20, 20);
}
}
}
function renderPlayers() {
const cam = getCamera();
// eigenen Spieler nur zeichnen, wenn er nicht gerade im/am Auto sitzt
if (!drivingCarId && !passengerCarId) {
drawPlayerBody(player.x - cam.x, player.y - cam.y, player.shirtColor, player.pantsColor, player.shoesColor, player.skin, player.shirtImage, player.pantsImage, player.shoesImage, player.skinImage, player.helmetImage);
drawNameTag(player.username, player.id, player.x - cam.x, player.y - cam.y - 20, player.gangTag, player.gangColor, player.title);
if (player.hasPackage) drawPackageIcon(player.x - cam.x, player.y - cam.y - 20);
if (player.cuffed) drawCuffedIcon(player.x - cam.x, player.y - cam.y - 20);
}
otherPlayers.forEach(p => {
const inCar = cars.some(c => c.driverId === p.id || c.passengerId === p.id);
if (p.world === player.world && !inCar) {
drawPlayerBody(p.x - cam.x, p.y - cam.y, p.shirtColor, p.pantsColor, p.shoesColor, p.skin, p.shirtImage, p.pantsImage, p.shoesImage, p.skinImage, p.helmetImage);
drawNameTag(p.username, p.id, p.x - cam.x, p.y - cam.y - 20, p.gangTag, p.gangColor, p.title);
if (p.hasPackage) drawPackageIcon(p.x - cam.x, p.y - cam.y - 20);
if (p.bounty > 0) drawBountyIcon(p.x - cam.x, p.y - cam.y - 20, p.bounty);
if (p.cuffed) drawCuffedIcon(p.x - cam.x, p.y - cam.y - 20);
}
});
}
function drawBountyIcon(px, py, amount) {
ctx.font = "14px Arial";
ctx.textAlign = "center";
ctx.fillText("💀", px, py - 46);
ctx.font = "bold 11px Arial";
ctx.fillStyle = "#f5d90a";
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.strokeText(`${amount}$`, px, py - 56);
ctx.fillText(`${amount}$`, px, py - 56);
ctx.textAlign = "left";
}
function drawCuffedIcon(px, py) {
ctx.font = "13px Arial";
ctx.textAlign = "center";
ctx.fillText("🔗", px, py - 34);
ctx.textAlign = "left";
}
function getDayBrightness() {
// 1 = heller Tag (Mittag), 0 = tiefste Nacht (Mitternacht)
return (Math.cos(((gameHour - 12) / 24) * Math.PI * 2) + 1) / 2;
}
function renderObjects() {
const map = maps[player.world];
if (!map || !map.objects) return;
const cam = getCamera();
const brightness = getDayBrightness();
const isNight = brightness < 0.35;
map.objects.forEach(o => {
const cfg = objectConfig[o.type];
if (!cfg) return;
const px = o.x - cam.x;
const py = o.y - (cfg.height - 32) - cam.y;
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);
}
// Laternen-Leuchteffekt nachts (unter dem Objekt gezeichnet)
if (isNight && cfg.glowsAtNight) {
const glowAlpha = (0.35 - brightness) / 0.35 * 0.5;
const grad = ctx.createRadialGradient(
cfg.width / 2, cfg.height / 2, 2,
cfg.width / 2, cfg.height / 2, 55
);
grad.addColorStop(0, `rgba(255, 230, 140, ${Math.max(0, glowAlpha)})`);
grad.addColorStop(1, "rgba(255, 230, 140, 0)");
ctx.fillStyle = grad;
ctx.fillRect(-55, -55, cfg.width + 110, cfg.height + 110);
}
ctx.fillStyle = cfg.color;
if (o.open) {
// Offenes Tor/Tür: durchsichtig gezeichnet, nur Umriss - man kann durchgehen
ctx.globalAlpha = 0.25;
ctx.fillRect(0, 0, cfg.width, cfg.height);
ctx.globalAlpha = 1;
ctx.strokeStyle = cfg.color;
ctx.setLineDash([4, 4]);
ctx.strokeRect(0, 0, cfg.width, cfg.height);
ctx.setLineDash([]);
} else {
ctx.fillRect(0, 0, cfg.width, cfg.height);
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, cfg.width, cfg.height);
}
ctx.restore();
});
}
function renderShops() {
const cam = getCamera();
shops.forEach(s => {
const px = s.x * 32 - cam.x;
const py = s.y * 32 - cam.y;
ctx.fillStyle = "yellow";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
});
}
function renderATMs() {
const cam = getCamera();
atms.forEach(a => {
const px = a.x * 32 - cam.x;
const py = a.y * 32 - cam.y;
ctx.fillStyle = "blue";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
});
}
function renderGarages() {
const cam = getCamera();
garages.forEach(g => {
const px = g.x * 32 - cam.x;
const py = g.y * 32 - cam.y;
ctx.fillStyle = "green";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
});
}
function renderJobcenters() {
const cam = getCamera();
jobcenters.forEach(j => {
const px = j.x * 32 - cam.x;
const py = j.y * 32 - cam.y;
ctx.fillStyle = "purple";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
});
}
function renderGasStations() {
const cam = getCamera();
gasStations.forEach(g => {
const px = g.x * 32 - cam.x;
const py = g.y * 32 - cam.y;
ctx.fillStyle = "#e67e22";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.fillStyle = "white";
ctx.font = "9px Arial";
ctx.fillText(g.price.toFixed(2) + "$", px + 1, py + 30);
});
}
function renderPointOfInterestGroup(list, color, icon) {
const cam = getCamera();
list.forEach(o => {
const px = o.x * 32 - cam.x;
const py = o.y * 32 - cam.y;
ctx.fillStyle = color;
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText(icon, px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderClothingShops() {
renderPointOfInterestGroup(clothingShops, "#e84393", "👕");
renderPointOfInterestGroup(trailerShops, "#8e6a3d", "🚛");
renderPointOfInterestGroup(highwayLinks, "#f0c419", "🛣️");
renderPointOfInterestGroup(blackMarketSpots, "#5a1a4a", "🕶️");
}
function renderInsuranceOffices() {
renderPointOfInterestGroup(insuranceOffices, "#0984e3", "🛡️");
}
function renderPlateOffices() {
renderPointOfInterestGroup(plateOffices, "#636e72", "🔖");
}
function renderRepairShops() {
const cam = getCamera();
repairShops.forEach(r => {
const px = r.x * 32 - cam.x;
const py = r.y * 32 - cam.y;
ctx.fillStyle = "#7f8c8d";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.fillStyle = "white";
ctx.font = "16px Arial";
ctx.fillText("🔧", px + 6, py + 22);
});
}
function renderHouses() {
const cam = getCamera();
houses.forEach(h => {
const px = h.x * 32 - cam.x;
const py = h.y * 32 - cam.y;
ctx.fillStyle = h.owned ? "#8e6b4a" : "#c9a876";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.fillStyle = "white";
ctx.font = "16px Arial";
ctx.fillText("🏠", px + 6, py + 22);
});
}
function renderJobPoints() {
const cam = getCamera();
jobPoints.forEach(jp => {
const px = jp.x * 32 - cam.x;
const py = jp.y * 32 - cam.y;
ctx.fillStyle = jp.kind === "dropoff" ? "#3498db" : "#2ecc71";
ctx.beginPath();
ctx.arc(px + 16, py + 16, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "black";
ctx.stroke();
ctx.fillStyle = "white";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillText(jp.kind === "dropoff" ? "📥" : "📤", px + 16, py + 20);
ctx.textAlign = "left";
ctx.fillStyle = "black";
ctx.font = "9px Arial";
ctx.fillText(jp.name, px, py + 34);
});
}
function renderTaxiStands() {
const cam = getCamera();
taxiStands.forEach(t => {
const px = t.x * 32 - cam.x;
const py = t.y * 32 - cam.y;
ctx.fillStyle = "#f5d90a";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🚕", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderHospitals() {
const cam = getCamera();
hospitals.forEach(h => {
const px = h.x * 32 - cam.x;
const py = h.y * 32 - cam.y;
ctx.fillStyle = "#e74c3c";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🏥", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderPrisons() {
const cam = getCamera();
prisons.forEach(pr => {
const px = pr.x * 32 - cam.x;
const py = pr.y * 32 - cam.y;
ctx.fillStyle = "#555555";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🔒", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderImpoundLots() {
const cam = getCamera();
impoundLots.forEach(l => {
const px = l.x * 32 - cam.x;
const py = l.y * 32 - cam.y;
ctx.fillStyle = "#b8860b";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🚛", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderFireStations() {
const cam = getCamera();
fireStations.forEach(s => {
const px = s.x * 32 - cam.x;
const py = s.y * 32 - cam.y;
ctx.fillStyle = "#c0392b";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🚒", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderHarvestSpots() {
const cam = getCamera();
harvestSpots.forEach(s => {
const px = s.x * 32 - cam.x;
const py = s.y * 32 - cam.y;
ctx.fillStyle = "#2ecc71";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("🌿", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderProcessSpots() {
const cam = getCamera();
processSpots.forEach(s => {
const px = s.x * 32 - cam.x;
const py = s.y * 32 - cam.y;
ctx.fillStyle = "#8e44ad";
ctx.fillRect(px, py, 32, 32);
ctx.strokeStyle = "black";
ctx.strokeRect(px, py, 32, 32);
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText("⚗️", px + 16, py + 22);
ctx.textAlign = "left";
});
}
function renderDealerSpots() {
const cam = getCamera();
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 200);
dealerSpots.forEach(s => {
const px = s.x * 32 - cam.x;
const py = s.y * 32 - cam.y;
ctx.strokeStyle = `rgba(230, 126, 34, ${0.4 + pulse * 0.3})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px + 16, py + 16, 22, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "#e67e22";
ctx.beginPath();
ctx.arc(px + 16, py + 16, 14, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "black";
ctx.stroke();
ctx.font = "14px Arial";
ctx.textAlign = "center";
ctx.fillText("💰", px + 16, py + 21);
ctx.font = "9px Arial";
ctx.fillStyle = "white";
ctx.fillText(`${s.drugName} - ${s.price}$`, px + 16, py + 40);
ctx.textAlign = "left";
});
}
function renderFires() {
if (fires.length === 0) return;
const cam = getCamera();
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 120);
fires.forEach(f => {
const px = f.x - cam.x;
const py = f.y - cam.y;
// Gefahrenradius andeuten
ctx.strokeStyle = `rgba(255, 100, 0, ${0.3 + pulse * 0.3})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, 70, 0, Math.PI * 2);
ctx.stroke();
ctx.font = `${22 + pulse * 6}px Arial`;
ctx.textAlign = "center";
ctx.fillText("🔥", px, py + 8);
ctx.font = "10px Arial";
ctx.fillStyle = "white";
ctx.fillText(`${f.intensity}%`, px, py + 26);
ctx.textAlign = "left";
});
}
function renderGroundDrops() {
if (groundDrops.length === 0) return;
const cam = getCamera();
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 200);
groundDrops.forEach(d => {
const px = d.x - cam.x;
const py = d.y - cam.y;
ctx.strokeStyle = `rgba(139, 90, 43, ${0.4 + pulse * 0.3})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, 18, 0, Math.PI * 2);
ctx.stroke();
ctx.font = `${16 + pulse * 4}px Arial`;
ctx.textAlign = "center";
ctx.fillText("📦", px, py + 6);
ctx.font = "9px Arial";
ctx.fillStyle = "white";
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.strokeText(d.label, px, py - 14);
ctx.fillText(d.label, px, py - 14);
ctx.textAlign = "left";
});
}
function renderTerritoryZones() {
const cam = getCamera();
territoryZones.forEach(z => {
const cx = z.x * 32 - cam.x + 16;
const cy = z.y * 32 - cam.y + 16;
const radius = 150;
const color = z.ownerColor || "#888888";
ctx.fillStyle = color + "22"; // sehr transparent
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash(z.ownerTag ? [] : [8, 6]);
ctx.stroke();
ctx.setLineDash([]);
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillStyle = "white";
const label = z.ownerTag ? `${z.name} [${z.ownerTag}]` : `${z.name} (frei)`;
ctx.fillText(label, cx, cy - radius - 6);
ctx.textAlign = "left";
});
}
// AUTOS (NEU)
function getUsernameById(id) {
if (id === player.id) return player.username;
const p = otherPlayers.find(p => p.id === id);
return p ? p.username : null;
}
function renderCars() {
const cam = getCamera();
const blinkOn = Math.floor(Date.now() / 400) % 2 === 0;
cars.forEach(c => {
if (c.world !== player.world) return;
const cfg = carConfigs[c.model] || carConfigs.sedan || { width: 40, height: 22, color: "#c0392b" };
const w = cfg.width;
const h = cfg.height;
ctx.save();
ctx.translate(c.x - cam.x, c.y - cam.y);
ctx.rotate(c.angle || 0);
// Karosserie
const carImg = carImageCache[c.model];
if (carImg && carImg.complete && carImg.naturalWidth > 0) {
ctx.drawImage(carImg, -w / 2, -h / 2, w, h);
ctx.strokeStyle = "black";
ctx.strokeRect(-w / 2, -h / 2, w, h);
} else {
ctx.fillStyle = c.paintColor || cfg.color;
ctx.fillRect(-w / 2, -h / 2, w, h);
ctx.strokeStyle = "black";
ctx.strokeRect(-w / 2, -h / 2, w, h);
}
// Front-Markierung: Fahrtrichtung ist immer +x (rechte Seite vor der Rotation) -
// kleines helles Dreieck zeigt eindeutig, wo "vorne" ist
ctx.fillStyle = "#eee";
ctx.beginPath();
ctx.moveTo(w / 2, -4);
ctx.lineTo(w / 2 + 6, 0);
ctx.lineTo(w / 2, 4);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "#555";
ctx.stroke();
// Scheinwerfer vorne (wenn eingeschaltet)
if (c.headlights) {
ctx.fillStyle = "#fff6b0";
ctx.beginPath();
ctx.arc(w / 2 - 2, -h / 2 + 3, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(w / 2 - 2, h / 2 - 3, 2.5, 0, Math.PI * 2);
ctx.fill();
}
// Bremslicht hinten (wenn gebremst wird)
if (c.brakeLight) {
ctx.fillStyle = "red";
ctx.fillRect(-w / 2, -h / 2, 3, h);
}
// Blinker (blinkend, links = beide Ecken oben, rechts = beide Ecken unten, Warnblinker = beide Seiten)
if (blinkOn) {
ctx.fillStyle = "orange";
if (c.leftBlinker || c.hazard) {
ctx.beginPath();
ctx.arc(w / 2 - 3, -h / 2 + 1, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(-w / 2 + 3, -h / 2 + 1, 2, 0, Math.PI * 2);
ctx.fill();
}
if (c.rightBlinker || c.hazard) {
ctx.beginPath();
ctx.arc(w / 2 - 3, h / 2 - 1, 2, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(-w / 2 + 3, h / 2 - 1, 2, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
// Nummernschild - unrotiert unter dem Auto, damit es immer lesbar ist
if (c.plate) {
const py = c.y - cam.y + h / 2 + 11;
const px = c.x - cam.x;
ctx.font = "bold 9px monospace";
ctx.textAlign = "center";
const textWidth = ctx.measureText(c.plate).width;
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.fillRect(px - textWidth / 2 - 3, py - 8, textWidth + 6, 11);
ctx.strokeStyle = "black";
ctx.lineWidth = 1;
ctx.strokeRect(px - textWidth / 2 - 3, py - 8, textWidth + 6, 11);
ctx.fillStyle = "black";
ctx.fillText(c.plate, px, py);
ctx.textAlign = "left";
}
// Blaulicht auf dem Dach (nur Job-Fahrzeuge, wenn eingeschaltet) - unrotiert,
// damit es unabhängig von der Fahrtrichtung sichtbar rotiert/blinkt
if (c.jobId && c.emergency) {
const flashRed = Math.floor(Date.now() / 200) % 2 === 0;
ctx.fillStyle = flashRed ? "#ff2b2b" : "#2b6bff";
ctx.beginPath();
ctx.arc(c.x - cam.x, c.y - cam.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 1;
ctx.stroke();
}
if (c.driverId) {
const name = getUsernameById(c.driverId);
if (name) drawNameTag(name, c.driverId, c.x - cam.x - w / 2, c.y - cam.y - h / 2);
}
if (c.passengerId) {
const name = getUsernameById(c.passengerId);
if (name) drawNameTag("🧑 " + name, c.passengerId, c.x - cam.x - w / 2, c.y - cam.y + h / 2 + 12);
}
// Rauch bei starkem Schaden (unter 40% Zustand)
if ((c.health ?? 100) < 40) {
const smokeAlpha = 0.15 + Math.random() * 0.15;
ctx.fillStyle = `rgba(80,80,80,${smokeAlpha})`;
ctx.beginPath();
ctx.arc(c.x - cam.x, c.y - cam.y - 10 - Math.random() * 6, 5 + Math.random() * 3, 0, Math.PI * 2);
ctx.fill();
}
});
}
// -------------------------------------------------------------
// MINI-MAP / RADAR (NEU)
// -------------------------------------------------------------
const minimapCanvas = document.getElementById("minimapCanvas");
const minimapCtx = minimapCanvas ? minimapCanvas.getContext("2d") : null;
const MINIMAP_RANGE = 500; // sichtbarer Radius in Pixel-Weltkoordinaten
function renderMinimap() {
if (!minimapCtx) return;
const size = minimapCanvas.width; // quadratisch
const scale = (size / 2) / MINIMAP_RANGE;
minimapCtx.clearRect(0, 0, size, size);
// Kreisförmige Maske
minimapCtx.save();
minimapCtx.beginPath();
minimapCtx.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
minimapCtx.clip();
minimapCtx.fillStyle = "#0a0a0a";
minimapCtx.fillRect(0, 0, size, size);
function toMini(wx, wy) {
return {
x: size / 2 + (wx - player.x) * scale,
y: size / 2 + (wy - player.y) * scale
};
}
function drawDot(wx, wy, color, r) {
const p2 = toMini(wx, wy);
if (p2.x < 0 || p2.x > size || p2.y < 0 || p2.y > size) return;
minimapCtx.fillStyle = color;
minimapCtx.beginPath();
minimapCtx.arc(p2.x, p2.y, r, 0, Math.PI * 2);
minimapCtx.fill();
}
// POIs
shops.forEach(s => drawDot(s.x * 32 + 16, s.y * 32 + 16, "yellow", 3));
garages.forEach(g => drawDot(g.x * 32 + 16, g.y * 32 + 16, "#2ecc71", 3));
jobcenters.forEach(j => drawDot(j.x * 32 + 16, j.y * 32 + 16, "purple", 3));
gasStations.forEach(g => drawDot(g.x * 32 + 16, g.y * 32 + 16, "#e67e22", 3));
clothingShops.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#e84393", 3));
trailerShops.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#8e6a3d", 3));
highwayLinks.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#f0c419", 3));
blackMarketSpots.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#5a1a4a", 3));
insuranceOffices.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#0984e3", 3));
plateOffices.forEach(o => drawDot(o.x * 32 + 16, o.y * 32 + 16, "#636e72", 3));
repairShops.forEach(r => drawDot(r.x * 32 + 16, r.y * 32 + 16, "#7f8c8d", 3));
houses.forEach(h => drawDot(h.x * 32 + 16, h.y * 32 + 16, "#c9a876", 3));
jobPoints.forEach(jp => drawDot(jp.x * 32 + 16, jp.y * 32 + 16, jp.kind === "dropoff" ? "#3498db" : "#2ecc71", 3.5));
taxiStands.forEach(t => drawDot(t.x * 32 + 16, t.y * 32 + 16, "#f5d90a", 3.5));
hospitals.forEach(h => drawDot(h.x * 32 + 16, h.y * 32 + 16, "#e74c3c", 3.5));
prisons.forEach(pr => drawDot(pr.x * 32 + 16, pr.y * 32 + 16, "#7f7f7f", 3.5));
impoundLots.forEach(l => drawDot(l.x * 32 + 16, l.y * 32 + 16, "#b8860b", 3.5));
fireStations.forEach(s => drawDot(s.x * 32 + 16, s.y * 32 + 16, "#c0392b", 3.5));
harvestSpots.forEach(s => drawDot(s.x * 32 + 16, s.y * 32 + 16, "#2ecc71", 3.5));
processSpots.forEach(s => drawDot(s.x * 32 + 16, s.y * 32 + 16, "#8e44ad", 3.5));
dealerSpots.forEach(s => drawDot(s.x * 32 + 16, s.y * 32 + 16, "#e67e22", 4.5));
fires.forEach(f => drawDot(f.x, f.y, "#ff5500", 5));
groundDrops.forEach(d => drawDot(d.x, d.y, "#8b5a2b", 4));
territoryZones.forEach(z => drawDot(z.x * 32 + 16, z.y * 32 + 16, z.ownerColor || "#888888", 4));
crimeAlerts.forEach(a => {
if (a.world === player.world) drawDot(a.x, a.y, a.kind === "medic" ? "#2ecc71" : "#ff2828", 4.5);
});
// Andere Spieler
otherPlayers.forEach(p => {
if (p.world === player.world) drawDot(p.x, p.y, p.bounty > 0 ? "#f5d90a" : (p.color || "#3498db"), p.bounty > 0 ? 4 : 3);
});
// Autos
cars.forEach(c => {
if (c.world === player.world) drawDot(c.x, c.y, "#c0392b", 2.5);
});
minimapCtx.restore();
// Rahmen
minimapCtx.strokeStyle = "#555";
minimapCtx.lineWidth = 2;
minimapCtx.beginPath();
minimapCtx.arc(size / 2, size / 2, size / 2 - 2, 0, Math.PI * 2);
minimapCtx.stroke();
// Spieler selbst (immer im Zentrum, als kleines Dreieck in Blickrichtung - hier simpel als Punkt)
minimapCtx.fillStyle = "yellow";
minimapCtx.beginPath();
minimapCtx.arc(size / 2, size / 2, 4, 0, Math.PI * 2);
minimapCtx.fill();
minimapCtx.strokeStyle = "black";
minimapCtx.lineWidth = 1;
minimapCtx.stroke();
}
function renderDayNightOverlay() {
const brightness = getDayBrightness();
const maxDarkness = 0.62;
const alpha = (1 - brightness) * maxDarkness;
if (alpha > 0.01) {
ctx.fillStyle = `rgba(10, 10, 40, ${alpha})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
function renderRain() {
if (weather !== "rain") return;
ctx.strokeStyle = "rgba(180, 200, 255, 0.5)";
ctx.lineWidth = 1;
const now = Date.now() / 1000;
rainDrops.forEach(d => {
const x = d.x * canvas.width;
const y = ((d.y * canvas.height) + now * d.speed * 40) % canvas.height;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - 3, y + 12);
ctx.stroke();
});
}
function renderCrimeAlerts() {
const now = Date.now();
crimeAlerts = crimeAlerts.filter(a => a.expiresAt > now);
if (crimeAlerts.length === 0) return;
const cam = getCamera();
const pulse = 0.5 + 0.5 * Math.sin(now / 150);
crimeAlerts.forEach(a => {
if (a.world !== player.world) return;
const px = a.x - cam.x;
const py = a.y - cam.y;
const isMedic = a.kind === "medic";
ctx.strokeStyle = isMedic
? `rgba(46, 204, 113, ${0.5 + pulse * 0.5})`
: `rgba(255, 40, 40, ${0.5 + pulse * 0.5})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(px, py, 16 + pulse * 6, 0, Math.PI * 2);
ctx.stroke();
ctx.font = "16px Arial";
ctx.textAlign = "center";
ctx.fillText(isMedic ? "⛑️" : "🚨", px, py + 5);
ctx.textAlign = "left";
});
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
renderMap();
renderObjects();
renderShops();
renderATMs();
renderGarages();
renderJobcenters();
renderGasStations();
renderClothingShops();
renderInsuranceOffices();
renderPlateOffices();
renderRepairShops();
renderHouses();
renderJobPoints();
renderTaxiStands();
renderHospitals();
renderPrisons();
renderImpoundLots();
renderFireStations();
renderHarvestSpots();
renderProcessSpots();
renderDealerSpots();
renderFires();
renderGroundDrops();
renderTerritoryZones();
renderCars();
renderDoors();
renderPlayers();
renderDayNightOverlay();
renderRain();
renderCrimeAlerts();
renderMinimap();
if (naviTarget) updateNaviDisplay();
requestAnimationFrame(render);
}
// -------------------------------------------------------------
// GAME LOOP
// -------------------------------------------------------------
function gameLoop() {
pollGamepad();
updateMovement();
setTimeout(gameLoop, 16);
updateStatusWindow();
updateDebugBox();
}
render();
gameLoop();
// -------------------------------------------------------------
// AUTO-LOGIN: nur noch über die Startseite index.html möglich - hier
// wird ausschließlich ein bereits vorhandener Token übernommen, ohne
// eigenes Login-Formular auf der Spielseite
// -------------------------------------------------------------
const storedToken = localStorage.getItem("token");
if (storedToken) {
loginBox.style.display = "none";
canvas.style.display = "block";
showLoadingScreen();
startMultiplayer(storedToken);
} else {
location.href = "/index.html";
}
// -------------------------------------------------------------
// MOBILE TOUCH-STEUERUNG: virtueller Joystick
// -------------------------------------------------------------
if (isTouchDevice) {
const joyBase = document.getElementById("touchJoystickBase");
const joyKnob = document.getElementById("touchJoystickKnob");
const JOY_RADIUS = 45; // maximaler Weg des Knopfs vom Zentrum, in px
const JOY_DEADZONE = 12;
let joyTouchId = null;
function resetJoystick() {
touchMove.w = touchMove.a = touchMove.s = touchMove.d = false;
joyKnob.style.transform = "translate(0px, 0px)";
}
function updateJoystickFromTouch(touch) {
const rect = joyBase.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let dx = touch.clientX - centerX;
let dy = touch.clientY - centerY;
const dist = Math.hypot(dx, dy);
if (dist > JOY_RADIUS) {
dx = (dx / dist) * JOY_RADIUS;
dy = (dy / dist) * JOY_RADIUS;
}
joyKnob.style.transform = `translate(${dx}px, ${dy}px)`;
touchMove.w = dy < -JOY_DEADZONE;
touchMove.s = dy > JOY_DEADZONE;
touchMove.a = dx < -JOY_DEADZONE;
touchMove.d = dx > JOY_DEADZONE;
}
joyBase.addEventListener("touchstart", e => {
e.preventDefault();
const touch = e.changedTouches[0];
joyTouchId = touch.identifier;
updateJoystickFromTouch(touch);
}, { passive: false });
joyBase.addEventListener("touchmove", e => {
e.preventDefault();
for (const touch of e.changedTouches) {
if (touch.identifier === joyTouchId) updateJoystickFromTouch(touch);
}
}, { passive: false });
function endJoystickTouch(e) {
for (const touch of e.changedTouches) {
if (touch.identifier === joyTouchId) {
joyTouchId = null;
resetJoystick();
}
}
}
joyBase.addEventListener("touchend", endJoystickTouch);
joyBase.addEventListener("touchcancel", endJoystickTouch);
// -------------------------------------------------------------
// MOBILE TOUCH-STEUERUNG: Action-Buttons (simulieren echte Tastendrücke,
// damit die komplette bestehende Logik inkl. Fenster-Sperre mitgilt)
// -------------------------------------------------------------
function simulateKeyPress(key) {
document.dispatchEvent(new KeyboardEvent("keydown", { key }));
document.dispatchEvent(new KeyboardEvent("keyup", { key }));
}
document.getElementById("touchBtnInteract").addEventListener("touchstart", e => {
e.preventDefault();
simulateKeyPress("e");
}, { passive: false });
document.getElementById("touchBtnJob").addEventListener("touchstart", e => {
e.preventDefault();
simulateKeyPress("j");
}, { passive: false });
document.getElementById("touchBtnPhone").addEventListener("touchstart", e => {
e.preventDefault();
simulateKeyPress("p");
}, { passive: false });
document.getElementById("touchBtnMenu").addEventListener("touchstart", e => {
e.preventDefault();
openCommandsWindow();
}, { passive: false });
}