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