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 = `${escapeHtml(from || "???")}: ${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 = ` ${escapeHtmlAdmin(label)} ${escapeHtmlAdmin(currentName)} ${isListening ? "Knopf drücken..." : "Neu zuordnen"} `; 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) -> -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 = ` 📩 ${escapeHtmlAdmin(username)} möchte dich hinzufügen Annehmen `; pendingEl.appendChild(row); }); const listEl = document.getElementById("contactsList"); listEl.innerHTML = ""; if (phoneContacts.length === 0) { listEl.innerHTML = `Noch keine Kontakte.`; } phoneContacts.forEach(c => { const row = document.createElement("div"); row.className = "contact-row"; row.innerHTML = ` ${escapeHtmlAdmin(c.username)} SMS Anrufen Entfernen `; listEl.appendChild(row); }); const select = document.getElementById("smsContactSelect"); select.innerHTML = phoneContacts.map(c => `${escapeHtmlAdmin(c.username)}`).join("") || `Keine Kontakte`; } 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 => ` ${escapeHtmlAdmin(s.symbol)} ${escapeHtmlAdmin(s.name)} ${Number(s.price).toFixed(2)}$ Kaufen `).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 = `Du besitzt noch keine Aktien.`; return; } panel.innerHTML = data.portfolio.map(p => { const value = (p.shares * Number(p.price)).toFixed(2); return ` ${escapeHtmlAdmin(p.symbol)} ${p.shares}x · Wert: ${value}$ ${Number(p.price).toFixed(2)}$ Verkaufen `; }).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]) => ` ${code}${escapeHtmlAdmin(label)} `).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 = `Aktuell keine Einheiten im Dienst.`; return; } list.innerHTML = units.map(u => ` ${JOB_TYPE_ICONS[u.jobType] || ""} ${escapeHtmlAdmin(u.username)} · ${escapeHtmlAdmin(u.world)} ${u.fmsStatus} · ${escapeHtmlAdmin(FMS_STATUS_LABELS[u.fmsStatus] || "?")} `).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 = `Noch keine Tickets erstellt.`; return; } const statusLabels = { open: "Offen", in_progress: "In Bearbeitung", closed: "Geschlossen" }; body.innerHTML = myTicketsCache.map(t => ` ${escapeHtmlAdmin(t.subject)} ${statusLabels[t.status] || t.status} #${t.id} · ${escapeHtmlAdmin(t.category)} · zuletzt aktualisiert ${new Date(t.updated_at).toLocaleString("de-DE")} `).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 = `#${data.ticket.id} ${escapeHtmlAdmin(data.ticket.subject)}Status: ${statusLabels[data.ticket.status] || data.ticket.status}`; const myUsername = player.username; const msgContainer = document.getElementById("ticketMessages"); msgContainer.innerHTML = data.messages.map(m => ` ${escapeHtmlAdmin(m.username)}${m.is_admin_reply ? " (Team)" : ""} · ${new Date(m.created_at).toLocaleString("de-DE")} ${escapeHtmlAdmin(m.message)} `).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 = `Kein Spieler namens "${escapeHtmlAdmin(data.username)}" gefunden.`; return; } const wantedText = data.wantedLevel > 0 ? `⭐ Gesucht (Level ${data.wantedLevel})` : `Nicht gesucht`; el.innerHTML = ` ${escapeHtmlAdmin(data.username)} (ID: ${data.playerId}) Status: ${data.online ? `🟢 Online (${escapeHtmlAdmin(data.world || "?")})` : "⚫ Offline"} Fahndung: ${wantedText} Kopfgeld: ${data.bounty > 0 ? `💀 ${data.bounty}$` : "-"} Leben: ${data.health}% ${data.jailed ? '🔒 In Haft' : ""} ${data.banned ? '🚫 Gebannt' : ""} `; } 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 = `Aktuell ist niemand gesucht.`; return; } el.innerHTML = ""; wanted.forEach(w => { const row = document.createElement("div"); row.className = "wanted-row"; row.innerHTML = ` ${escapeHtmlAdmin(w.username)} ${"⭐".repeat(w.wantedLevel)} (${escapeHtmlAdmin(w.world)}) ${w.bounty > 0 ? `💀 ${w.bounty}$` : ""} `; 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 = `Kein Fahrzeug mit Kennzeichen "${escapeHtmlAdmin(data.plate)}" gefunden.`; return; } const wantedText = data.ownerWanted > 0 ? `⭐ Gesucht (Level ${data.ownerWanted})` : `Nicht gesucht`; el.innerHTML = ` Kennzeichen: ${escapeHtmlAdmin(data.plate)} Fahrzeug: ${escapeHtmlAdmin(data.model)}${data.isTrailer ? " (Anhänger)" : ""} Halter: ${escapeHtmlAdmin(data.ownerName)} Fahndung des Halters: ${wantedText} Kopfgeld auf Halter: ${data.ownerBounty > 0 ? `💀 ${data.ownerBounty}$` : "-"} Welt: ${escapeHtmlAdmin(data.world)} `; } 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 ${msg.purchasePrice}$ (/shop buy).`; 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} - $ Preis speichern `; } else { div.innerHTML = ` ${item.name} - ${item.price}$ Kaufen `; } 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 ? `Herausholen` : `Einlagern`} `; 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 = `aktueller Job`; } else if (isEntry) { btn = `Bewerben`; } else { btn = `nur per Beförderung`; } return ` Rang ${r.level}: ${r.title} (${r.salary}$/Min) ${btn} `; }).join(""); jobDiv.innerHTML = ` ${job.name} ${ranksHtml} `; list.appendChild(jobDiv); }); if (currentRankId) { const quitDiv = document.createElement("div"); quitDiv.style.marginTop = "10px"; quitDiv.innerHTML = `Job kündigen`; 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 = ` ${item.name || item.id} x${item.amount} → Kofferraum `; 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 = ` ${item.name || item.id} x${item.amount} → Inventar `; 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 = ` ${item.name || item.id} x${item.amount} → Haus `; 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 = ` ${item.name || item.id} x${item.amount} → Inventar `; 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 = ` ${escapeHtmlAdmin(itemDisplayName(item))} x${item.amount} → Schrank `; 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 = ` ${escapeHtmlAdmin(itemDisplayName(item))} x${item.amount} → Inventar `; 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 = "Legende (klicken = Navi-Ziel setzen)"; poiGroups.forEach(group => { if (group.list.length === 0) return; legend.innerHTML += `● ${group.icon} ${group.label} (${group.list.length})`; }); // 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 += ``; }); row.innerHTML = ` ${escapeHtmlAdmin(item.label)} ${inputsHtml} Ausführen `; 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 = ` ${TUNE_STAT_LABELS[stat]} - Stufe ${level}/${msg.maxLevel} ${cost === null ? "Maximal" : `Aufwerten (${cost}$)`} `; body.appendChild(row); }); const paintRow = document.createElement("div"); paintRow.className = "job-menu-row"; paintRow.innerHTML = ` 🎨 Lackierung (${msg.paintCost}$) Lackieren `; 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 = `Du hast aktuell keinen Job mit Menü-Aktionen. (Taxi, Polizei, Sanitäter, Abschlepper haben eins)`; return; } title.textContent = `📋 Job-Menü - ${msg.jobName || msg.jobType}`; if (msg.jobType === "taxi") { if (!msg.requests || msg.requests.length === 0) { body.innerHTML = `Aktuell wartet niemand auf ein Taxi.`; return; } msg.requests.forEach(r => { const row = document.createElement("div"); row.className = "job-menu-row"; row.innerHTML = ` ${escapeHtmlAdmin(r.username)} (${escapeHtmlAdmin(r.world)}) Abholen `; 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 += `Aktuell ist niemand gesucht.`; } else { msg.wanted.forEach(w => { const row = document.createElement("div"); row.className = "job-menu-row"; row.innerHTML = ` ${escapeHtmlAdmin(w.username)} ${"⭐".repeat(w.wantedLevel)} (${escapeHtmlAdmin(w.world)}) Sofort verhaften Fesseln `; 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 += `Aktuell niemand gefesselt.`; } else { msg.cuffed.forEach(c => { const row = document.createElement("div"); row.className = "job-menu-row"; let buttons = `Losbinden`; if (c.inMyCar) { buttons += `Aus Auto holen`; buttons += `Am Gefängnis abliefern`; } else if (!c.inAnyCar && msg.isDriving) { buttons += `Ins Auto setzen`; } row.innerHTML = ` ${escapeHtmlAdmin(c.username)} 🔗 (${escapeHtmlAdmin(c.world)})${c.inMyCar ? " - bei dir im Auto" : c.inAnyCar ? " - in einem Auto" : ""} ${buttons} `; body.appendChild(row); }); } } if (msg.jobType === "medic") { if (!msg.injured || msg.injured.length === 0) { body.innerHTML = `Aktuell ist niemand verletzt.`; return; } msg.injured.forEach(i => { const row = document.createElement("div"); row.className = "job-menu-row"; row.innerHTML = ` ${escapeHtmlAdmin(i.username)} - ${i.health}% Leben (${escapeHtmlAdmin(i.world)}) Heilen `; body.appendChild(row); }); } if (msg.jobType === "mechanic") { if (!msg.damaged || msg.damaged.length === 0) { body.innerHTML = `Kein beschädigtes Fahrzeug in der Nähe.`; return; } msg.damaged.forEach(d => { const row = document.createElement("div"); row.className = "job-menu-row"; row.innerHTML = ` ${escapeHtmlAdmin(d.model)} (${escapeHtmlAdmin(d.ownerName)}) - ${d.health}% Zustand Reparieren `; body.appendChild(row); }); } if (msg.jobType === "tow") { if (!msg.isDriving) { body.innerHTML = `Du musst im Abschleppwagen sitzen, um diese Aktionen zu nutzen.`; return; } if (msg.isTowing) { body.innerHTML = ` Fahrzeug angehängt Am Abschlepphof abliefern (+50$) Loslassen `; } else { body.innerHTML = ` Kein Fahrzeug angehängt Freistehendes Fahrzeug anhängen `; } } if (msg.jobType === "fire") { if (!msg.fires || msg.fires.length === 0) { body.innerHTML = `Aktuell brennt nichts.`; return; } msg.fires.forEach(f => { const row = document.createElement("div"); row.className = "job-menu-row"; row.innerHTML = ` 🔥 Feuer bei (${f.x}, ${f.y}) - ${f.intensity}% Löschen `; 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 = `Aktuell wartet niemand auf ein Taxi.`; } 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 = ` ${escapeHtmlAdmin(r.username)} (${escapeHtmlAdmin(r.world)}) Abholen `; 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 = ` ${escapeHtmlAdmin(pl.username)} #${pl.id} — ${escapeHtmlAdmin(pl.world)} (${pl.x}, ${pl.y}) Setzen Teleport zu Sperren `; 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)} Hunger: ${Math.round(player.hunger)} Durst: ${Math.round(player.thirst)} 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 = ` 👕 ${escapeHtmlAdmin(label)} x${item.amount} Anziehen `; } else { div.innerHTML = ` ${item.name || item.id} x${item.amount} Benutzen `; } 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 = `Aktuell ist kein Anhänger-Modell im Angebot.`; } else { catalog.forEach(item => { const row = document.createElement("div"); row.className = "clothing-catalog-row"; row.innerHTML = ` ${escapeHtmlAdmin(item.model)} ${item.price}$ Kaufen `; 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 = `Noch nichts im Angebot.`; return; } items.forEach(item => { const row = document.createElement("div"); row.className = "clothing-catalog-row"; row.innerHTML = ` ${escapeHtmlAdmin(item.name)} ${item.price}$ `; 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 }); }
Du besitzt noch keine Aktien.
Aktuell keine Einheiten im Dienst.
Noch keine Tickets erstellt.
Aktuell ist niemand gesucht.
/shop buy
Du hast aktuell keinen Job mit Menü-Aktionen. (Taxi, Polizei, Sanitäter, Abschlepper haben eins)
Aktuell wartet niemand auf ein Taxi.
Aktuell niemand gefesselt.
Aktuell ist niemand verletzt.
Kein beschädigtes Fahrzeug in der Nähe.
Du musst im Abschleppwagen sitzen, um diese Aktionen zu nutzen.
Aktuell brennt nichts.
Aktuell ist kein Anhänger-Modell im Angebot.