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