Files
bordervillegame/game3d.html
T
2026-08-22 08:40:29 +02:00

4442 lines
191 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Borderville</title>
<style>
html, body { margin: 0; padding: 0; overflow: hidden; background: #111; font-family: Arial, sans-serif; }
#canvas3d { display: block; width: 100vw; height: 100vh; }
#infoOverlay {
position: fixed; top: 10px; left: 10px;
background: rgba(0,0,0,0.75); color: #eee; padding: 12px 16px;
border-radius: 8px; font-size: 13px; max-width: 320px; z-index: 100;
border: 1px solid #444;
}
#infoOverlay h3 { margin: 0 0 6px 0; color: #f0c419; }
#infoOverlay p { margin: 4px 0; color: #aaa; }
#infoOverlay a { color: #5b8def; }
#statusLine {
position: fixed; bottom: 10px; left: 10px;
background: rgba(0,0,0,0.75); color: #6fbf73; padding: 6px 12px;
border-radius: 6px; font-size: 12px; z-index: 100;
}
#hud {
position: fixed; top: 10px; right: 10px;
background: rgba(0,0,0,0.75); color: #eee; padding: 10px 14px;
border-radius: 8px; font-size: 13px; z-index: 100; border: 1px solid #444;
display: flex; flex-direction: column; gap: 4px; min-width: 140px;
}
#hud .hud-row { display: flex; justify-content: space-between; gap: 12px; }
#minimapWrap3d {
position: fixed; top: 175px; right: 10px; width: 150px; height: 150px;
border-radius: 50%; overflow: hidden; border: 2px solid #444; z-index: 100;
background: #0d1a10;
}
body.touch-device3d #minimapWrap3d { top: 10px; right: 10px; }
#minimapWanted3d {
position: fixed; top: 330px; right: 10px; width: 150px; text-align: center;
font-size: 15px; letter-spacing: 2px; z-index: 100; color: #e74c3c; display: none;
}
body.touch-device3d #minimapWanted3d { top: 165px; }
#pingIndicator3d {
position: fixed; top: 10px; left: 10px; padding: 4px 10px;
background: rgba(0,0,0,0.6); border-radius: 6px; font-size: 11px;
color: #6fd98a; z-index: 100; display: flex; align-items: center; gap: 5px;
}
#pingIndicator3d .dot3d { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
body.touch-device3d #pingIndicator3d { left: 62px; } /* neben dem Chat-Toggle-Button */
#settingsBtn3d {
position: fixed; top: 10px; right: 165px; width: 34px; height: 34px;
border-radius: 8px; background: rgba(0,0,0,0.6); color: white; border: 1px solid #444;
z-index: 200; font-size: 16px; cursor: pointer;
}
body.touch-device3d #settingsBtn3d { top: 60px; right: 10px; }
#settingsPanel3d {
position: fixed; top: 50px; right: 165px; width: 220px;
background: rgba(15,15,20,0.97); color: #eee; padding: 14px;
border-radius: 8px; z-index: 300; display: none; border: 1px solid #444;
}
body.touch-device3d #settingsPanel3d { top: 100px; right: 10px; }
#settingsPanel3d h4 { margin: 0 0 8px; color: #f0c419; font-size: 13px; }
#settingsPanel3d input[type="range"] { width: 100%; }
#settingsPanel3d .val3d { font-size: 12px; color: #888; text-align: right; }
#reconnectOverlay3d {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); z-index: 500; display: none;
color: #eee; align-items: center; justify-content: center; flex-direction: column;
text-align: center; font-size: 16px;
}
#reconnectOverlay3d .spinner3d {
width: 36px; height: 36px; border: 4px solid #444; border-top-color: #5b8def;
border-radius: 50%; animation: spin3d 0.9s linear infinite; margin-bottom: 14px;
}
@keyframes spin3d { to { transform: rotate(360deg); } }
#eventBanner3d {
position: fixed; top: 60px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.75); color: #f0c419; padding: 6px 16px;
border-radius: 6px; font-size: 13px; z-index: 100; display: none;
}
#hotwireGame3d {
position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%);
background: rgba(15,15,20,0.97); color: #eee; padding: 20px 26px;
border-radius: 8px; z-index: 400; display: none; text-align: center;
border: 2px solid #e74c3c; width: 320px;
}
#hotwireBar3d {
position: relative; width: 100%; height: 24px; background: #222;
border-radius: 4px; margin: 14px 0; overflow: hidden;
}
#hotwireZone3d {
position: absolute; top: 0; height: 100%; background: #2c7a3d;
}
#hotwireNeedle3d {
position: absolute; top: -3px; width: 4px; height: 30px; background: #f0c419;
}
#fishingGame3d {
position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%);
background: rgba(15,15,20,0.97); color: #eee; padding: 20px 26px;
border-radius: 8px; z-index: 400; display: none; text-align: center;
border: 2px solid #3498db; width: 320px;
}
#fishingBar3d {
position: relative; width: 100%; height: 24px; background: #222;
border-radius: 4px; margin: 14px 0; overflow: hidden;
}
#fishingZone3d {
position: absolute; top: 0; height: 100%; background: #2c7a3d;
}
#fishingNeedle3d {
position: absolute; top: -3px; width: 4px; height: 30px; background: #f0c419;
}
#damageFlash3d {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 250; opacity: 0;
background: radial-gradient(ellipse at center, rgba(200,0,0,0) 40%, rgba(180,0,0,0.55) 100%);
transition: opacity 0.15s ease-out;
}
#rainOverlay3d {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 50; display: none;
background: repeating-linear-gradient(
115deg, rgba(180,200,255,0) 0px, rgba(180,200,255,0.12) 2px,
rgba(180,200,255,0) 4px, rgba(180,200,255,0) 22px
);
animation: rainScroll3d 0.3s linear infinite;
}
@keyframes rainScroll3d { from { background-position: 0 0; } to { background-position: -40px 60px; } }
#snowOverlay3d {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 50; display: none;
background-image:
radial-gradient(circle, white 1px, transparent 1.5px),
radial-gradient(circle, white 1px, transparent 1.5px);
background-size: 60px 60px, 90px 90px;
background-position: 0 0, 30px 20px;
opacity: 0.7;
animation: snowFall3d 4s linear infinite;
}
@keyframes snowFall3d { from { background-position: 0 0, 30px 20px; } to { background-position: 40px 400px, 70px 460px; } }
#hud .hud-row .label { color: #888; }
#inventoryPanel {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 480px; max-height: 75vh;
background: rgba(15,15,20,0.97); color: #eee; padding: 16px;
border-radius: 8px; font-size: 13px; z-index: 300; border: 2px solid #5b8def;
overflow-y: auto; display: none;
}
#inventoryPanel h4 { margin: 0 0 10px 0; color: #f0c419; font-size: 16px; }
#inventoryPanel .close-btn { float: right; cursor: pointer; }
.inv-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 4px; border-bottom: 1px solid #333; font-size: 13px; }
.inv-row button { background: #5b8def; border: none; color: white; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
#chatBox3d {
position: fixed; bottom: 40px; left: 10px; width: 380px; height: 220px;
background: rgba(0,0,0,0.7); border-radius: 8px; border: 1px solid #444;
z-index: 100; display: flex; flex-direction: column; overflow: hidden;
}
#chatMessages3d { flex: 1; overflow-y: auto; padding: 8px; font-size: 12px; color: #ddd; }
#chatMessages3d div { margin-bottom: 3px; }
#chatInputRow3d { display: flex; border-top: 1px solid #444; }
#chatInputRow3d input {
flex: 1; background: #111; border: none; color: #eee; padding: 8px;
font-size: 12px; outline: none;
}
#chatInputRow3d button { background: #5b8def; border: none; color: white; padding: 0 14px; cursor: pointer; }
#interactPrompt {
position: fixed; bottom: 260px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.8); color: #f0c419; padding: 8px 16px;
border-radius: 6px; font-size: 14px; z-index: 100; display: none;
}
/* Touch-Steuerung - nur auf Touch-Geräten eingeblendet (siehe JS) */
#touchControls3d { display: none; }
body.touch-device3d #touchControls3d { display: block; }
body.touch-device3d #hud { top: auto; bottom: 150px; }
/* Foto-Modus: komplette Oberfläche ausblenden, nur die 3D-Szene bleibt sichtbar */
body.photo-mode3d #hud, body.photo-mode3d #minimapWrap3d, body.photo-mode3d #minimapWanted3d,
body.photo-mode3d #chatBox3d, body.photo-mode3d #interactPrompt, body.photo-mode3d #eventBanner3d,
body.photo-mode3d #carHud3d, body.photo-mode3d #pingIndicator3d, body.photo-mode3d #settingsBtn3d,
body.photo-mode3d #touchControls3d, body.photo-mode3d #chatToggleBtn3d, body.photo-mode3d #statusLine {
display: none !important;
}
#photoModeHint3d {
position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.5); font-size: 11px; z-index: 100; display: none;
}
body.photo-mode3d #photoModeHint3d { display: block; }
body.touch-device3d #chatBox3d { display: none; } /* Chat nimmt auf Handy zu viel Platz weg, öffnet sich per Button */
body.touch-device3d #chatToggleBtn3d { display: block; }
#joystickZone3d {
position: fixed; left: 20px; bottom: 20px; width: 130px; height: 130px;
border-radius: 50%; background: rgba(255,255,255,0.08); border: 2px solid rgba(255,255,255,0.25);
z-index: 200; touch-action: none;
}
#joystickKnob3d {
position: absolute; left: 35px; top: 35px; width: 60px; height: 60px;
border-radius: 50%; background: rgba(255,255,255,0.35);
}
#cameraTouchZone3d {
position: fixed; right: 0; top: 0; width: 55%; height: 70%;
z-index: 150; touch-action: none;
}
.touch-btn3d {
position: fixed; width: 56px; height: 56px; border-radius: 50%;
background: rgba(91,141,239,0.6); border: 2px solid rgba(255,255,255,0.4);
color: white; font-size: 20px; z-index: 200; touch-action: none;
display: flex; align-items: center; justify-content: center;
}
#chatToggleBtn3d {
position: fixed; top: 10px; left: 10px; width: 44px; height: 44px;
border-radius: 8px; background: rgba(0,0,0,0.6); color: white; border: 1px solid #444;
z-index: 200; display: none; font-size: 18px;
}
.panel3d {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 420px; max-height: 70vh; overflow-y: auto;
background: rgba(15,15,20,0.97); color: #ddd; border: 2px solid #5b8def;
border-radius: 8px; padding: 16px; z-index: 300; display: none;
}
.panel3d h3 { margin: 0 0 10px 0; color: #7fb0ec; }
.panel3d .row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #333; font-size: 13px; }
.panel3d input { background: #14151a; border: 1px solid #444; color: #ddd; border-radius: 4px; padding: 6px 8px; width: 100px; }
.panel3d button { background: #5b8def; border: none; color: white; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.panel3d button.secondary { background: #444; }
.panel3d .close-btn { float: right; }
.panel3d .msg3d { margin-top: 8px; font-size: 12px; color: #e0a83a; }
#carHud3d {
position: fixed; bottom: 10px; right: 10px;
background: rgba(0,0,0,0.75); color: #eee; padding: 10px 14px;
border-radius: 8px; font-size: 13px; z-index: 100; border: 1px solid #444;
display: none; min-width: 140px;
}
#carHud3d .hud-row { display: flex; justify-content: space-between; gap: 12px; }
.job-menu-row {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid #2a2a2a; font-size: 13px; gap: 10px;
}
.jmBtn {
padding: 6px 12px; background: #2c7a3d; border: none; color: white;
cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap;
}
.jmBtn.secondary { background: #555; }
.jmBtn.wide { width: 100%; margin-top: 8px; }
.tab-btns3d { display: flex; gap: 6px; margin-bottom: 10px; }
.tab-btns3d button { background: #333; border: none; color: #ccc; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.tab-btns3d button.active { background: #5b8def; color: white; }
.tab-panel3d { display: none; }
.tab-panel3d.active { display: block; }
.result-card3d { background: #1f1f2a; padding: 10px; border-radius: 6px; font-size: 12px; line-height: 1.6; }
.wanted-row3d { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #2a2a2a; font-size: 12px; }
.panel3d input[type="text"] { width: 100%; box-sizing: border-box; margin-bottom: 6px; }
.contact-row3d { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #2a2a2a; font-size: 12px; }
.contact-row3d button { background: #5b8def; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 11px; }
.contact-row3d button.call3d { background: #2c7a3d; }
.contact-row3d button.call3d:disabled { background: #444; cursor: not-allowed; }
#incomingCallBox3d, #outgoingCallBox3d, #activeCallBar3d {
position: fixed; top: 20%; left: 50%; transform: translateX(-50%);
background: rgba(15,15,20,0.97); color: #eee; padding: 16px 24px;
border-radius: 8px; z-index: 400; display: none; text-align: center;
border: 2px solid #2c7a3d;
}
#incomingCallBox3d button, #outgoingCallBox3d button { margin: 8px 6px 0; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.call-accept3d { background: #2c7a3d; color: white; }
.call-decline3d { background: #7a2c2c; color: white; }
#activeCallBar3d {
top: auto; bottom: 10px; left: 50%; transform: translateX(-50%);
display: none; padding: 8px 16px;
}
#activeCallBar3d button { background: #7a2c2c; border: none; color: white; padding: 6px 14px; border-radius: 4px; cursor: pointer; margin-left: 10px; font-size: 12px; }
#smsMessages3d { height: 180px; overflow-y: auto; background: #14151a; border-radius: 6px; padding: 8px; margin-bottom: 6px; font-size: 12px; }
.clothing-row3d { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #2a2a2a; font-size: 12px; }
.clothing-row3d .swatch3d { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
.clothing-row3d span:nth-child(2) { flex: 1; }
.clothing-row3d button { background: #5b8def; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 11px; }
.ticket-row3d { padding: 8px; margin-bottom: 6px; background: #1f1f2a; border-radius: 6px; cursor: pointer; font-size: 12px; }
.ticket-row3d .top { display: flex; justify-content: space-between; }
.ticket-status3d { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #444; }
.ticket-status3d.open { background: #2c7a3d; }
.ticket-status3d.in_progress { background: #a87c1f; }
.ticket-msg3d { padding: 6px 8px; margin-bottom: 6px; border-radius: 6px; font-size: 12px; background: #1f1f2a; }
.ticket-msg3d.mine { background: #223355; }
#ticketMessages3d { max-height: 200px; overflow-y: auto; margin-bottom: 8px; }
.ls-unit-row3d {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 10px; margin-bottom: 5px; border-radius: 6px;
background: #1f1f2a; border: 1px solid #333; font-size: 13px;
}
.fms-btn3d {
display: flex; justify-content: space-between; padding: 8px 12px; margin-bottom: 5px;
border-radius: 6px; cursor: pointer; background: #222; border: 1px solid #444; color: #ddd; font-size: 13px;
}
.fms-btn3d.active3d { background: #2c7a8a; border-color: #2c7a8a; color: white; }
.stock-row3d {
display: flex; justify-content: space-between; align-items: center;
padding: 10px; margin-bottom: 6px; border-radius: 6px;
background: #1f1f2a; border: 1px solid #333; font-size: 12px;
}
.stock-row3d .stock-symbol3d { font-weight: bold; color: #6fd98a; }
.stock-row3d .stock-name3d { font-size: 10px; color: #888; }
.stock-row3d input[type="number"] { width: 50px; }
.stock-row3d button { background: #4fae6a; border: none; color: white; padding: 5px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; }
.stock-row3d button.sell3d { background: #c15c5c; }
.trunk-columns3d { display: flex; gap: 12px; }
.trunk-columns3d > div { flex: 1; }
.trunk-columns3d h4 { color: #f0c419; font-size: 13px; margin: 0 0 6px 0; }
</style>
</head>
<body>
<canvas id="canvas3d"></canvas>
<div id="infoOverlay">
<h3>🎮 Borderville 3D</h3>
<p>Charaktere und Fahrzeuge nutzen jetzt echte 3D-Modelle (kann beim ersten Laden kurz dauern). Gebäude/Deko aus dem 3D-Editor sind weiterhin einfache Formen.</p>
<p>Steuerung: <b>WASD</b> zum Laufen/Fahren, <b>E</b> zum Interagieren/Ein-Aussteigen, <b>I</b> Inventar, <b>J</b> Job-Menü, <b>O</b> Tablet, <b>P</b> Handy, <b>T</b> Kofferraum, <b>Q</b> Tanken/Reparieren (im Auto), <b>L</b> Blaulicht/Sirene (im Auto), <b>V</b> Ich-Sicht umschalten, <b>F</b> Foto-Modus, <b>Maus ziehen</b> zum Umschauen.</p>
<p>Chat-Befehle: <b>/ticket</b>, <b>/leitstelle</b>, <b>/börse</b>, <b>/verkaufen</b>, <b>/gang ...</b>, <b>/gate claim</b>, <b>/admin duty</b></p>
</div>
<div id="statusLine">Verbinde...</div>
<div id="photoModeHint3d">📷 Foto-Modus - WASD zum Bewegen, F zum Beenden</div>
<div id="eventBanner3d"></div>
<div id="damageFlash3d"></div>
<div id="rainOverlay3d"></div>
<div id="snowOverlay3d"></div>
<div id="hotwireGame3d">
<h3 style="margin:0 0 6px; color:#e74c3c;">🔌 Kurzschließen</h3>
<p style="font-size:12px; color:#aaa; margin:0;">LEERTASTE drücken, wenn die Nadel im grünen Bereich ist!</p>
<div id="hotwireBar3d">
<div id="hotwireZone3d"></div>
<div id="hotwireNeedle3d"></div>
</div>
</div>
<div id="fishingGame3d">
<h3 style="margin:0 0 6px; color:#3498db;">🎣 Angeln</h3>
<p style="font-size:12px; color:#aaa; margin:0;">LEERTASTE drücken, wenn die Nadel im grünen Bereich ist!</p>
<div id="fishingBar3d">
<div id="fishingZone3d"></div>
<div id="fishingNeedle3d"></div>
</div>
</div>
<div id="incomingCallBox3d">
<div id="incomingCallText3d"></div>
<button class="call-accept3d" onclick="acceptIncomingCall3d()">Annehmen</button>
<button class="call-decline3d" onclick="declineIncomingCall3d()">Ablehnen</button>
</div>
<div id="outgoingCallBox3d">
<div id="outgoingCallText3d"></div>
</div>
<div id="activeCallBar3d">
<span id="activeCallText3d"></span>
<button onclick="endCall3d()">Auflegen</button>
</div>
<div id="hud">
<div class="hud-row"><span class="label">💵 Bargeld</span><span id="hudMoney">-</span></div>
<div class="hud-row"><span class="label">🏦 Bank</span><span id="hudBank">-</span></div>
<div class="hud-row"><span class="label">❤️ Leben</span><span id="hudHealth">-</span></div>
<div class="hud-row"><span class="label">🍗 Hunger</span><span id="hudHunger">-</span></div>
<div class="hud-row"><span class="label">💧 Durst</span><span id="hudThirst">-</span></div>
</div>
<div id="minimapWrap3d"><canvas id="minimapCanvas3d" width="150" height="150"></canvas></div>
<div id="minimapWanted3d"></div>
<div id="pingIndicator3d"><span class="dot3d"></span><span id="pingText3d">-- ms</span></div>
<button id="settingsBtn3d" onclick="toggleSettingsPanel3d()">⚙️</button>
<div id="settingsPanel3d">
<h4>Kamera-Empfindlichkeit</h4>
<input type="range" id="mouseSensSlider3d" min="0.2" max="3" step="0.1" value="1" oninput="updateMouseSensitivity3d(this.value)">
<div class="val3d" id="mouseSensValue3d">1.0x</div>
</div>
<div id="reconnectOverlay3d">
<div class="spinner3d"></div>
<div id="reconnectText3d">Verbindung unterbrochen - versuche erneut zu verbinden...</div>
</div>
<div class="panel3d" id="inventoryPanel">
<button class="close-btn" onclick="closePanel3d('inventoryPanel')"></button>
<h4>🎒 Inventar</h4>
<div id="inventoryList3d"></div>
</div>
<div id="chatBox3d">
<div id="chatMessages3d"></div>
<div id="chatInputRow3d">
<input type="text" id="chatInput3d" placeholder="Nachricht... (Enter zum Senden)">
<button onclick="sendChat3d()">Senden</button>
</div>
</div>
<div id="interactPrompt">[E] Interagieren</div>
<button id="chatToggleBtn3d" onclick="document.getElementById('chatBox3d').style.display = document.getElementById('chatBox3d').style.display === 'flex' ? 'none' : 'flex';">💬</button>
<div id="touchControls3d">
<div id="cameraTouchZone3d"></div>
<div id="joystickZone3d"><div id="joystickKnob3d"></div></div>
<button class="touch-btn3d" id="touchBtnE3d" style="right:20px; bottom:110px;">E</button>
<button class="touch-btn3d" id="touchBtnI3d" style="right:90px; bottom:110px;">🎒</button>
<button class="touch-btn3d" id="touchBtnJ3d" style="right:20px; bottom:180px;">📋</button>
</div>
<div id="carHud3d">
<div class="hud-row"><span>🚗</span><span id="carSpeed3d">0 km/h</span></div>
<div class="hud-row"><span></span><span id="carFuel3d">-</span></div>
<div class="hud-row"><span>🔧</span><span id="carHealth3d">-</span></div>
</div>
<div class="panel3d" id="shopPanel3d">
<button class="close-btn" onclick="closePanel3d('shopPanel3d')"></button>
<h3>🛒 Shop</h3>
<div id="shopItems3d"></div>
<div class="msg3d" id="shopMsg3d"></div>
</div>
<div class="panel3d" id="atmPanel3d">
<button class="close-btn" onclick="closePanel3d('atmPanel3d')"></button>
<h3>🏧 Bankautomat</h3>
<div class="row"><span>Bargeld</span><span id="atmMoney3d">-</span></div>
<div class="row"><span>Bank</span><span id="atmBank3d">-</span></div>
<p style="font-size:12px; color:#888; margin-top:10px;">Betrag</p>
<input type="number" id="atmAmount3d" placeholder="Betrag">
<div style="margin-top:8px; display:flex; gap:6px;">
<button onclick="atmDeposit3d()">Einzahlen</button>
<button onclick="atmWithdraw3d()">Auszahlen</button>
</div>
<div class="msg3d" id="atmMsg3d"></div>
<h4 style="color:#f0c419; margin-top:14px;">PIN ändern</h4>
<input type="password" id="atmOldPin3d" placeholder="Aktuelle PIN (leer beim ersten Mal)" maxlength="4">
<input type="password" id="atmNewPin3d" placeholder="Neue PIN (4 Ziffern)" maxlength="4">
<button onclick="atmSetPin3d()" style="margin-top:6px;">PIN speichern</button>
<div class="msg3d" id="atmPinMsg3d"></div>
</div>
<div class="panel3d" id="garagePanel3d">
<button class="close-btn" onclick="closePanel3d('garagePanel3d')"></button>
<h3>🚗 Garage</h3>
<div id="garageCars3d"></div>
<div class="msg3d" id="garageMsg3d"></div>
</div>
<div class="panel3d" id="jobMenuPanel3d">
<button class="close-btn" onclick="closePanel3d('jobMenuPanel3d')"></button>
<h3 id="jobMenuTitle3d">📋 Job-Menü</h3>
<div id="jobMenuBody3d"></div>
</div>
<div class="panel3d" id="tabletPanel3d">
<button class="close-btn" onclick="closePanel3d('tabletPanel3d')"></button>
<h3>🖥️ Einsatz-Tablet</h3>
<div class="tab-btns3d">
<button class="active" data-tablet-tab="search" onclick="showTabletTab3d('search')">Personensuche</button>
<button data-tablet-tab="wanted" onclick="showTabletTab3d('wanted')">Fahndung</button>
<button data-tablet-tab="plate" onclick="showTabletTab3d('plate')">Kennzeichen</button>
<button data-tablet-tab="fms" onclick="showTabletTab3d('fms')">FMS-Status</button>
</div>
<div class="tab-panel3d active" id="tabletTab-search">
<input type="text" id="tabletSearchInput3d" placeholder="Benutzername...">
<button onclick="tabletSearchPerson3d()">Suchen</button>
<div id="tabletSearchResult3d" style="margin-top:10px;"></div>
</div>
<div class="tab-panel3d" id="tabletTab-wanted">
<button onclick="tabletLoadWantedList3d()" style="margin-bottom:10px;">Aktualisieren</button>
<div id="tabletWantedList3d"></div>
</div>
<div class="tab-panel3d" id="tabletTab-plate">
<input type="text" id="tabletPlateInput3d" placeholder="Kennzeichen...">
<button onclick="tabletLookupPlate3d()">Abfragen</button>
<div id="tabletPlateResult3d" style="margin-top:10px;"></div>
</div>
<div class="tab-panel3d" id="tabletTab-fms">
<p style="color:#888; font-size:12px; margin-top:0;">Status wird an alle Kollegen deiner Berufsgruppe gemeldet.</p>
<div id="fmsStatusButtons3d"></div>
</div>
</div>
<div class="panel3d" id="ticketPanel3d">
<button class="close-btn" onclick="closePanel3d('ticketPanel3d')"></button>
<h3>🎫 Support-Tickets</h3>
<div id="ticketListView3d">
<button onclick="openNewTicketPanel3d()" style="margin-bottom:10px;">+ Neues Ticket</button>
<div id="ticketListBody3d"></div>
</div>
<div id="ticketNewView3d" style="display:none;">
<select id="ticketNewCategory3d" style="width:100%; padding:6px; margin-bottom:6px;">
<option value="bug">Fehler melden</option>
<option value="spieler">Spieler melden</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="text" id="ticketNewSubject3d" placeholder="Betreff">
<textarea id="ticketNewMessage3d" placeholder="Beschreibung..." style="width:100%; box-sizing:border-box; min-height:80px; background:#14151a; border:1px solid #444; color:#ddd; border-radius:4px; padding:8px; margin-top:6px;"></textarea>
<div style="display:flex; gap:6px; margin-top:8px;">
<button onclick="submitNewTicket3d()">Absenden</button>
<button class="secondary" onclick="showTicketListView3d()">Zurück</button>
</div>
</div>
<div id="ticketDetailView3d" style="display:none;">
<div id="ticketDetailHeader3d" style="margin-bottom:8px; font-size:13px;"></div>
<div id="ticketMessages3d"></div>
<div style="display:flex; gap:6px;">
<input type="text" id="ticketReplyText3d" placeholder="Antwort..." style="flex:1;">
<button onclick="submitTicketReply3d()">Senden</button>
</div>
<button class="secondary" onclick="showTicketListView3d()" style="margin-top:8px;">Zurück zur Liste</button>
</div>
</div>
<div class="panel3d" id="leitstellePanel3d">
<button class="close-btn" onclick="closePanel3d('leitstellePanel3d')"></button>
<h3>📻 Leitstelle</h3>
<div id="leitstelleUnits3d" style="max-height:250px; overflow-y:auto; margin-bottom:10px;"></div>
<select id="leitstelleTargetJob3d" style="width:100%; padding:6px; margin-bottom:6px;">
<option value="police">🚓 Polizei</option>
<option value="medic">⛑️ Rettungsdienst</option>
<option value="fire">🚒 Feuerwehr</option>
</select>
<textarea id="leitstelleDispatchText3d" placeholder="Einsatzmeldung..." style="width:100%; box-sizing:border-box; min-height:60px; background:#14151a; border:1px solid #444; color:#ddd; border-radius:4px; padding:8px;"></textarea>
<button onclick="sendLeitstelleDispatch3d()" style="margin-top:6px;">Einsatz verteilen</button>
</div>
<div class="panel3d" id="stockPanel3d">
<button class="close-btn" onclick="closePanel3d('stockPanel3d')"></button>
<h3>📈 Börse</h3>
<div class="tab-btns3d">
<button class="active" data-stock-tab="list" onclick="showStockTab3d('list')">Markt</button>
<button data-stock-tab="portfolio" onclick="showStockTab3d('portfolio')">Mein Portfolio</button>
</div>
<div class="tab-panel3d active" id="stockTab-list"></div>
<div class="tab-panel3d" id="stockTab-portfolio"></div>
</div>
<div class="panel3d" id="trunkPanel3d">
<button class="close-btn" onclick="closePanel3d('trunkPanel3d')"></button>
<h3>🧳 Kofferraum</h3>
<div class="trunk-columns3d">
<div>
<h4>Dein Inventar</h4>
<div id="trunkInvList3d"></div>
</div>
<div>
<h4>Im Kofferraum</h4>
<div id="trunkContentList3d"></div>
</div>
</div>
</div>
<div class="panel3d" id="trailerShopPanel3d">
<button class="close-btn" onclick="closePanel3d('trailerShopPanel3d')"></button>
<h3>🚛 Anhänger-Shop</h3>
<div id="trailerShopCatalog3d"></div>
</div>
<div class="panel3d" id="tunePanel3d">
<button class="close-btn" onclick="closePanel3d('tunePanel3d')"></button>
<h3>🔧 Tuning</h3>
<div id="tuneBody3d"></div>
</div>
<div class="panel3d" id="wardrobePanel3d">
<button class="close-btn" onclick="closePanel3d('wardrobePanel3d')"></button>
<h3>👔 Kleiderschrank</h3>
<div class="trunk-columns3d">
<div>
<h4>Dein Inventar</h4>
<div id="wardrobeInvList3d"></div>
</div>
<div>
<h4>Im Schrank</h4>
<div id="wardrobeContentList3d"></div>
</div>
</div>
</div>
<div class="panel3d" id="phonePanel3d">
<button class="close-btn" onclick="closePanel3d('phonePanel3d')"></button>
<h3>📱 Handy</h3>
<div class="tab-btns3d">
<button class="active" data-phone-tab="contacts" onclick="showPhoneTab3d('contacts')">Kontakte</button>
<button data-phone-tab="sms" onclick="showPhoneTab3d('sms')">SMS</button>
</div>
<div class="tab-panel3d active" id="phoneTab-contacts">
<input type="text" id="phoneAddInput3d" placeholder="Benutzername...">
<button onclick="phoneAddContact3d()">Kontaktanfrage senden</button>
<div id="pendingRequests3d" style="margin-top:10px;"></div>
<div id="contactsList3d" style="margin-top:10px;"></div>
</div>
<div class="tab-panel3d" id="phoneTab-sms">
<select id="smsContactSelect3d" onchange="openSmsThread3d()" style="width:100%; padding:6px; margin-bottom:8px;"></select>
<div id="smsMessages3d"></div>
<div style="display:flex; gap:6px;">
<input type="text" id="smsInput3d" placeholder="Nachricht..." style="flex:1;">
<button onclick="sendSms3d()">Senden</button>
</div>
</div>
</div>
<div class="panel3d" id="clothingPanel3d">
<button class="close-btn" onclick="closePanel3d('clothingPanel3d')"></button>
<h3>👕 Kleidungsladen</h3>
<div class="tab-btns3d">
<button class="active" data-clothing-tab="shirt" onclick="showClothingTab3d('shirt')">Shirts</button>
<button data-clothing-tab="pants" onclick="showClothingTab3d('pants')">Hosen</button>
<button data-clothing-tab="shoes" onclick="showClothingTab3d('shoes')">Schuhe</button>
<button data-clothing-tab="helmet" onclick="showClothingTab3d('helmet')">Helme</button>
</div>
<div class="tab-panel3d active" id="clothingTab-shirt"></div>
<div class="tab-panel3d" id="clothingTab-pants"></div>
<div class="tab-panel3d" id="clothingTab-shoes"></div>
<div class="tab-panel3d" id="clothingTab-helmet"></div>
</div>
<div class="panel3d" id="housePanel3d">
<button class="close-btn" onclick="closePanel3d('housePanel3d')"></button>
<h3 id="houseName3d">🏡 Haus</h3>
<div id="houseBuySection3d" style="display:none;">
<p>Preis: <span id="housePrice3d"></span></p>
<button onclick="buyHouse3d()">Kaufen</button>
</div>
<div id="houseLockedSection3d" style="display:none;">
<p style="color:#e06c6c;">🔒 Verschlossen - du hast keinen Schlüssel.</p>
</div>
<div id="houseForRentSection3d" style="display:none;">
<p>🏠 Wird zur Miete angeboten.</p>
<p>Miete: <span id="houseRentPrice3d"></span> pro Intervall</p>
<button onclick="rentHouse3d()">Mieten</button>
</div>
<div id="houseStorageSection3d" style="display:none;">
<h4 style="color:#f0c419;">Dein Inventar</h4>
<div id="houseInvList3d"></div>
<h4 style="color:#f0c419; margin-top:12px;">Im Haus</h4>
<div id="houseStorageList3d"></div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/utils/SkeletonUtils.js"></script>
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2/dist/livekit-client.umd.min.js"></script>
<script>
// -------------------------------------------------------------
// AUTH: denselben Token wie das echte Spiel nutzen, sonst zur
// Startseite weiterleiten (kein eigenes Login hier)
// -------------------------------------------------------------
const token = localStorage.getItem("token");
if (!token) {
alert("Bitte zuerst im echten Spiel einloggen.");
location.href = "/index.html";
}
// -------------------------------------------------------------
// ECHTE 3D-MODELLE (statt Platzhalter-Formen) - offizielle, dauerhaft
// gehostete Khronos-Beispielmodelle (glTF-Sample-Assets, CC BY 4.0):
// CesiumMan = animierter, texturierter Mensch; CesiumMilkTruck =
// texturiertes Fahrzeug. Werden einmal vorgeladen, danach klont jede
// Spielfigur/jedes Auto ihre eigene unabhängige Kopie davon.
// -------------------------------------------------------------
const MODEL_BASE = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/";
const preloadedModels = { human: null, truck: null };
function loadModel(url) {
return new Promise((resolve, reject) => {
new THREE.GLTFLoader().load(url, resolve, undefined, reject);
});
}
// Skaliert ein geladenes Modell so, dass es eine bestimmte Ziel-Größe
// hat (Ursprungsgröße der Sample-Modelle ist uns nicht exakt bekannt,
// diese Normierung macht die Größe unabhängig davon vorhersehbar)
function normalizeScale(object3d, targetSize, axis) {
const box = new THREE.Box3().setFromObject(object3d);
const size = box.getSize(new THREE.Vector3());
const current = axis === "y" ? size.y : Math.max(size.x, size.z);
return current > 0 ? targetSize / current : 1;
}
const activeMixers3d = []; // { mixer } - werden jeden Frame aktualisiert
async function preloadModels3d() {
try {
if (myPlayerId === null) statusLine.textContent = "Lade 3D-Modelle...";
const truckGltf = await loadModel(MODEL_BASE + "CesiumMilkTruck/glTF-Binary/CesiumMilkTruck.glb");
preloadedModels.truck = {
scene: truckGltf.scene,
animations: truckGltf.animations,
scale: normalizeScale(truckGltf.scene, 2.2, "xz") // Zielgröße: ~2,2 Einheiten lang
};
// Nicht mehr überschreiben, falls die WS-Verbindung inzwischen (parallel
// dazu) schon erfolgreich durch ist - sonst verschwindet fälschlich der
// "✅ Verbunden als..."-Text wieder, obwohl alles funktioniert
if (myPlayerId === null) statusLine.textContent = "Modelle geladen, verbinde...";
} catch (err) {
console.error("[3D-Modelle] Laden fehlgeschlagen, nutze Platzhalter:", err);
if (myPlayerId === null) statusLine.textContent = "Modelle konnten nicht geladen werden - nutze Platzhalter...";
}
}
// -------------------------------------------------------------
// SZENE AUFBAUEN
// -------------------------------------------------------------
// -------------------------------------------------------------
// PERFORMANCE-ANPASSUNG: grobe Geräte-Einschätzung beim Start
// (Touch-Gerät oder wenige CPU-Kerne = wahrscheinlich schwächer),
// danach laufende Bildrate-Überwachung, die bei anhaltend niedrigen
// Werten automatisch nachjustiert (nur abwärts, kein Umschalten hin
// und her - vermeidet Ruckeln durch ständiges Wechseln)
// -------------------------------------------------------------
let lowQualityMode3d = false;
{
const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const fewCores = (navigator.hardwareConcurrency || 8) <= 4;
if (isTouch || fewCores) lowQualityMode3d = true;
}
function applyQualityMode3d() {
if (lowQualityMode3d) {
renderer.shadowMap.enabled = false;
renderer.setPixelRatio(1);
} else {
renderer.shadowMap.enabled = true;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
}
}
const canvas = document.getElementById("canvas3d");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: !lowQualityMode3d });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 60, 220);
applyQualityMode3d();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 1000);
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Licht (grob passend zum bestehenden Tag/Nacht-Gefühl - hier erstmal
// einfach konstant hell für den Test)
const sun = new THREE.DirectionalLight(0xffffff, 1.0);
sun.position.set(50, 80, 30);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -100;
sun.shadow.camera.right = 100;
sun.shadow.camera.top = 100;
sun.shadow.camera.bottom = -100;
scene.add(sun);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
// -------------------------------------------------------------
// BODEN AUS DEN ECHTEN KACHELDATEN AUFBAUEN
// (Annahme wie beim Karten-Generator: 0 = Gras, 1 = Straße - je
// Kachel ein kleines, flaches Segment mit passender Farbe, statt
// einer riesigen Textur - für den Test unkompliziert genug)
// -------------------------------------------------------------
const TILE_SIZE = 1; // 3D-Welteinheiten pro Kachel (1 Server-Kachel = 32px = 1 Einheit hier)
const groundGroup = new THREE.Group();
scene.add(groundGroup);
// -------------------------------------------------------------
// PROZEDURALE TEXTUREN: statt flacher Einheitsfarbe eine per Canvas
// gezeichnete Textur (spart externe Bilddateien, sieht aber deutlich
// "echter" aus als eine reine Vollfarbe)
// -------------------------------------------------------------
function makeGrassTexture() {
const c = document.createElement("canvas");
c.width = c.height = 64;
const ctx2d = c.getContext("2d");
ctx2d.fillStyle = "#4a8f3c";
ctx2d.fillRect(0, 0, 64, 64);
// zufällige, etwas dunklere/hellere Halme andeuten
for (let i = 0; i < 90; i++) {
const shade = Math.random() > 0.5 ? "#3f7a33" : "#5aa347";
ctx2d.fillStyle = shade;
ctx2d.fillRect(Math.random() * 64, Math.random() * 64, 2, 2);
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 1);
tex.magFilter = THREE.NearestFilter; // bewusst "grob" statt verwaschen-weich
return tex;
}
function makeRoadTexture() {
const c = document.createElement("canvas");
c.width = c.height = 64;
const ctx2d = c.getContext("2d");
ctx2d.fillStyle = "#3a3a3a";
ctx2d.fillRect(0, 0, 64, 64);
// leichtes Rauschen für Asphalt-Optik
for (let i = 0; i < 150; i++) {
ctx2d.fillStyle = Math.random() > 0.5 ? "#333333" : "#444444";
ctx2d.fillRect(Math.random() * 64, Math.random() * 64, 1, 1);
}
// gestrichelte Mittellinie
ctx2d.fillStyle = "#d4c060";
for (let y = 4; y < 64; y += 14) ctx2d.fillRect(30, y, 4, 8);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return tex;
}
const grassTexture = makeGrassTexture();
const roadTexture = makeRoadTexture();
let tileConfigCache3d = {};
fetch("/api/tile_config")
.then(res => res.json())
.then(data => { tileConfigCache3d = data || {}; })
.catch(() => {});
let objectConfigCache3d = {};
fetch("/api/object_config")
.then(res => res.json())
.then(data => { objectConfigCache3d = data || {}; })
.catch(() => {});
// -------------------------------------------------------------
// 2D-KARTENOBJEKTE (aus map_editor.html, z.B. Mauern/Zäune/Tore) -
// komplett getrennt vom 3D-Editor-System (editorObjectsGroup). Werden
// als einfache Boxen dargestellt; Tore (action=toggle_gate) blockieren
// nur im geschlossenen Zustand und lassen sich per E öffnen/schließen
// (der Server erkennt das genau wie im 2D-Spiel automatisch anhand
// der Spielerposition, hier muss nur noch reagiert/dargestellt werden)
// -------------------------------------------------------------
// -------------------------------------------------------------
// STRASSENSPERREN: von Polizei aufgestellt, blockieren Fußgänger UND
// Fahrzeuge (Server ist die Autorität dafür - hier nur Anzeige +
// clientseitige Kollision fürs sofortige Gefühl, keine Wartezeit auf
// die Server-Korrektur)
// -------------------------------------------------------------
// -------------------------------------------------------------
// INTERAKTIONS-RADIUS-RING: zeigt statt nur dem Text-Hinweis auch
// visuell am Boden, wie nah man an einem Punkt sein muss - hellt sich
// auf, sobald tatsächlich etwas in Reichweite ist. Deckt die aus
// map_data bekannten Standort-Listen + Fahrzeuge ab (keine exakte
// 1:1-Kopie der Server-Prüfung, aber eine sehr gute Annäherung)
// -------------------------------------------------------------
const INTERACT_RANGE_3D = 1.3; // ~40px, wie car_enter im Server
const INTERACT_POI_KEYS_3D = [
"shops", "atms", "garages", "jobcenters", "gasStations", "repairShops",
"taxiStands", "hospitals", "prisons", "insuranceOffices", "plateOffices",
"clothingShops", "trailerShops", "blackMarketSpots"
];
const interactRing3d = new THREE.Mesh(
new THREE.RingGeometry(INTERACT_RANGE_3D - 0.05, INTERACT_RANGE_3D, 32),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25, side: THREE.DoubleSide })
);
interactRing3d.rotation.x = -Math.PI / 2;
interactRing3d.position.y = 0.03;
scene.add(interactRing3d);
function isNearInteractable3d() {
for (const key of INTERACT_POI_KEYS_3D) {
const list = lastMapData3d[key];
if (!Array.isArray(list)) continue;
for (const poi of list) {
if (Math.hypot(poi.x / 32 - myX, poi.y / 32 - myY) < INTERACT_RANGE_3D) return true;
}
}
if (!drivingCarId && !passengerCarId) {
for (const mesh of carMeshes.values()) {
if (mesh.visible && Math.hypot(mesh.position.x - myX, mesh.position.z - myY) < INTERACT_RANGE_3D) return true;
}
}
return false;
}
function updateInteractRing3d() {
interactRing3d.position.x = myX;
interactRing3d.position.z = myY;
interactRing3d.visible = !drivingCarId; // beim Fahren keine Rolle
const near = interactRing3d.visible && isNearInteractable3d();
interactRing3d.material.color.setHex(near ? 0x6fd98a : 0xffffff);
interactRing3d.material.opacity = near ? 0.5 : 0.2;
document.getElementById("interactPrompt").style.display = near ? "block" : "none";
}
const roadblockGroup3d = new THREE.Group();
scene.add(roadblockGroup3d);
let roadblocks3d = []; // { x, z, id } - in 3D-Einheiten (bereits /32)
function buildRoadblocks3d(list) {
while (roadblockGroup3d.children.length) roadblockGroup3d.remove(roadblockGroup3d.children[0]);
roadblocks3d = (list || []).map(r => ({ id: r.id, x: r.x / 32, z: r.y / 32 }));
roadblocks3d.forEach(r => {
const group = new THREE.Group();
// 2 Böcke mit rot-weißer Sperrlatte dazwischen, klassisches
// Straßensperren-Aussehen
[-0.6, 0.6].forEach(xOff => {
const stand = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.7, 0.4),
new THREE.MeshLambertMaterial({ color: 0xdddddd })
);
stand.position.set(xOff, 0.35, 0);
group.add(stand);
});
const bar = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 0.15, 0.15),
new THREE.MeshLambertMaterial({ color: 0xe74c3c })
);
bar.position.set(0, 0.55, 0);
group.add(bar);
group.position.set(r.x, 0, r.z);
group.castShadow = true;
roadblockGroup3d.add(group);
});
}
function isBlockedByRoadblocks3d(x, z) {
for (const r of roadblocks3d) {
if (Math.hypot(x - r.x, z - r.z) < 1.0) return true; // ~32px Radius, wie im Server
}
return false;
}
const mapObjectsGroup3d = new THREE.Group();
scene.add(mapObjectsGroup3d);
let mapObjectMeshes3d = []; // { mesh, data, collisionRadius }
function buildMapObjects3d(objects) {
while (mapObjectsGroup3d.children.length) mapObjectsGroup3d.remove(mapObjectsGroup3d.children[0]);
mapObjectMeshes3d = [];
// WICHTIG: für jeden Eintrag in "objects" muss es genau einen Platz in
// mapObjectMeshes3d geben (auch wenn kein Mesh erzeugt wird), sonst
// verschiebt sich der Index gegenüber dem Server bei gate_toggle
(objects || []).forEach(o => {
const cfg = objectConfigCache3d[o.type];
if (!cfg) {
mapObjectMeshes3d.push(null);
return;
}
const wUnits = (cfg.width || 32) / 32;
const hUnits = (cfg.height || 32) / 32;
const isGate = cfg.action === "toggle_gate";
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(wUnits, hUnits, wUnits),
new THREE.MeshLambertMaterial({ color: cfg.color || "#8a8a8a" })
);
mesh.position.set(o.x / 32, hUnits / 2, o.y / 32);
if (o.rot) mesh.rotation.y = (o.rot * Math.PI) / 180;
mesh.castShadow = true;
mesh.visible = !(isGate && o.open); // offenes Tor beiseite/unsichtbar
mapObjectsGroup3d.add(mesh);
mapObjectMeshes3d.push({
mesh, data: o, cfg, isGate,
collisionRadius: cfg.collision ? Math.max(wUnits, hUnits) / 2 : 0
});
});
}
function isBlockedByMapObjects3d(x, z) {
for (const entry of mapObjectMeshes3d) {
if (!entry || entry.collisionRadius <= 0) continue;
if (entry.isGate && entry.data.open) continue; // offenes Tor blockiert nicht
if (Math.hypot(x - entry.mesh.position.x, z - entry.mesh.position.z) < entry.collisionRadius + 0.3) {
return true;
}
}
return false;
}
function buildGroundFromTiles(tiles) {
// alte Bodengeometrie entfernen, falls Karte gewechselt wurde
while (groundGroup.children.length) groundGroup.remove(groundGroup.children[0]);
const geo = new THREE.BoxGeometry(TILE_SIZE, 0.1, TILE_SIZE);
const rows = tiles.length;
const cols = tiles[0].length;
// Kacheln nach ihrer echten ID gruppieren (nicht mehr nur "Straße"
// oder "Gras") - jede in eurem Kachel-Editor angelegte Sorte
// bekommt hier ihre eigene, konfigurierte Farbe
const groups = {}; // tileId -> Liste von [x,y]
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const id = tiles[y][x];
(groups[id] = groups[id] || []).push([x, y]);
}
}
const dummy = new THREE.Object3D();
for (const [tileId, list] of Object.entries(groups)) {
const cfg = tileConfigCache3d[tileId];
// Straße (isRoad) bekommt weiterhin die Asphalt-Textur mit
// Mittellinie, alles andere eine Fläche in der jeweils
// konfigurierten Farbe (Fallback: Gras-Textur/-Grün, falls
// die Kachel-ID noch nicht in eurem Editor konfiguriert ist)
let mat;
if (cfg?.isRoad) {
mat = new THREE.MeshLambertMaterial({ map: roadTexture });
} else if (cfg?.color) {
mat = new THREE.MeshLambertMaterial({ color: cfg.color });
} else {
mat = new THREE.MeshLambertMaterial({ map: grassTexture });
}
const mesh = new THREE.InstancedMesh(geo, mat, list.length);
mesh.receiveShadow = true;
list.forEach(([x, y], i) => {
dummy.position.set(x * TILE_SIZE, 0, y * TILE_SIZE);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
groundGroup.add(mesh);
}
}
// -------------------------------------------------------------
// KARTENPUNKTE (ATM, Shops, Tankstellen, Krankenhäuser usw.) - werden
// als einfache, klar erkennbare Marker dargestellt (Symbol auf einem
// Sockel), analog zu den farbigen Kästchen im 2D-Spiel
// -------------------------------------------------------------
const poiGroup = new THREE.Group();
scene.add(poiGroup);
const POI_MARKER_TYPES = [
{ key: "atms", color: 0xf0c419, label: "🏧", height: 1.2 },
{ key: "shops", color: 0x5b8def, label: "🛒", height: 1.4 },
{ key: "garages", color: 0x8e6a3d, label: "🚗", height: 1.4 },
{ key: "gasStations", color: 0xe74c3c, label: "⛽", height: 1.4 },
{ key: "repairShops", color: 0x7f8c8d, label: "🔧", height: 1.4 },
{ key: "hospitals", color: 0xff4d4d, label: "🏥", height: 1.6 },
{ key: "fireStations", color: 0xc0392b, label: "🚒", height: 1.6 },
{ key: "jobcenters", color: 0x27ae60, label: "💼", height: 1.4 },
{ key: "clothingShops", color: 0xe84393, label: "👕", height: 1.4 },
{ key: "houses", color: 0xd4a574, label: "🏡", height: 1.6 },
{ key: "taxiStands", color: 0xf5d90a, label: "🚕", height: 1.4 },
{ key: "trailerShops", color: 0x8e6a3d, label: "🚛", height: 1.4 },
{ key: "highwayLinks", color: 0xf0c419, label: "🛣️", height: 1.0 },
{ key: "blackMarketSpots", color: 0x5a1a4a, label: "🕶️", height: 1.4 },
{ key: "territoryZones", color: 0x9b59b6, label: "🚩", height: 1.4 },
{ key: "dealerSpots", color: 0xe67e22, label: "💰", height: 1.2 }
];
function makeLabelSprite(text) {
const c = document.createElement("canvas");
c.width = 64; c.height = 64;
const ctx2d = c.getContext("2d");
ctx2d.font = "42px sans-serif";
ctx2d.textAlign = "center";
ctx2d.textBaseline = "middle";
ctx2d.fillText(text, 32, 34);
const tex = new THREE.CanvasTexture(c);
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(0.8, 0.8, 0.8);
return sprite;
}
// Breitere Variante für längeren Text (z.B. Wegweiser-Beschriftungen),
// die normale makeLabelSprite ist nur für kurze Emoji-Labels ausgelegt
// Zeigt kurz eine Sprechblase mit dem Chat-Text über einer Spielfigur
// (eigene oder fremde) - entfernt eine noch laufende alte Blase an
// derselben Figur zuerst, damit sich nichts überlagert
function showChatBubble3d(mesh, text) {
if (mesh.userData.chatBubble) {
mesh.remove(mesh.userData.chatBubble);
clearTimeout(mesh.userData.chatBubbleTimeout);
}
const shortText = text.length > 40 ? text.slice(0, 40) + "…" : text;
const bubble = makeTextSprite3d(shortText);
bubble.position.y = 2.0; // über dem Kopf, oberhalb von Hut/Emote-Blase
mesh.add(bubble);
mesh.userData.chatBubble = bubble;
mesh.userData.chatBubbleTimeout = setTimeout(() => {
mesh.remove(bubble);
mesh.userData.chatBubble = null;
}, 4000);
}
// Malt eine Sprühfarben-Textur mit Text direkt auf ein Objekt-Mesh
// (z.B. die Graffiti-Tafel) - identisch zur Editor-Version
function paintGraffitiTexture3d(mesh, text, color) {
const canvas = document.createElement("canvas");
canvas.width = 512; canvas.height = 320;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(0, 0, 512, 320);
ctx.globalAlpha = 0.25;
ctx.fillStyle = color;
for (let i = 0; i < 14; i++) {
ctx.beginPath();
ctx.arc(Math.random() * 512, Math.random() * 320, 15 + Math.random() * 35, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (text) {
ctx.fillStyle = color;
ctx.font = "bold 64px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.save();
ctx.translate(256, 160);
ctx.rotate(-0.05);
ctx.fillText(text, 0, 0);
ctx.restore();
}
const tex = new THREE.CanvasTexture(canvas);
mesh.material.map = tex;
mesh.material.color.setHex(0xffffff);
mesh.material.needsUpdate = true;
}
function makeTextSprite3d(text) {
const c = document.createElement("canvas");
c.width = 256; c.height = 64;
const ctx2d = c.getContext("2d");
ctx2d.fillStyle = "rgba(20,20,20,0.85)";
ctx2d.fillRect(0, 0, 256, 64);
ctx2d.fillStyle = "#ffffff";
ctx2d.font = "28px sans-serif";
ctx2d.textAlign = "center";
ctx2d.textBaseline = "middle";
ctx2d.fillText(text, 128, 32);
const tex = new THREE.CanvasTexture(c);
const mat = new THREE.SpriteMaterial({ map: tex });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(1.6, 0.4, 1);
return sprite;
}
let lastMapData3d = {};
function buildPoiMarkers(mapData) {
lastMapData3d = mapData;
while (poiGroup.children.length) poiGroup.remove(poiGroup.children[0]);
POI_MARKER_TYPES.forEach(def => {
const list = mapData[def.key];
if (!Array.isArray(list)) return;
list.forEach(poi => {
const group = new THREE.Group();
// Territorium-Zonen: echte Bande-Farbe nutzen, falls erobert
// (poi.ownerColor), sonst die normale Typ-Farbe (unerobert/neutral)
const markerColor = (def.key === "territoryZones" && poi.ownerColor) ? poi.ownerColor : def.color;
const pole = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, def.height, 8),
new THREE.MeshLambertMaterial({ color: markerColor })
);
pole.position.y = def.height / 2;
pole.castShadow = true;
group.add(pole);
const labelText = (def.key === "territoryZones" && poi.ownerTag) ? `${def.label} ${poi.ownerTag}` : def.label;
const sprite = makeLabelSprite(labelText);
sprite.position.y = def.height + 0.5;
group.add(sprite);
// ATMs/Shops usw. liegen in Server-Tile-Koordinaten (nicht Pixel)
group.position.set(poi.x, 0, poi.y);
poiGroup.add(group);
});
});
}
// -------------------------------------------------------------
// 3D-OBJEKTE AUS DEM EDITOR: pro Welt vom Server laden und anzeigen
// -------------------------------------------------------------
// -------------------------------------------------------------
// FAHRZEUGE: einfache Box-Platzhalter statt echter Auto-Modelle,
// Position/Winkel kommen komplett vom Server (der rechnet die
// gesamte Fahrphysik, der Client sendet nur throttle/steer)
// -------------------------------------------------------------
const carMeshes = new Map(); // carId -> THREE.Group
let drivingCarId = null;
let passengerCarId = null;
// Falls das Truck-Modell nicht in Fahrtrichtung ausgerichtet ist,
// hier den Winkel anpassen (in Grad) - lässt sich ohne Testen im
// Browser nicht exakt vorherbestimmen, ist aber eine einzige Zahl
const TRUCK_ROTATION_OFFSET_DEG = 0;
// Von Spielern im Editor gebaute Fahrzeug-Modelle (model-Name -> Bauplan)
// - haben IMMER Vorrang vor dem Standard-Platzhalter-Truck
let carModelBlueprints3d = {};
fetch("/api/car_models_3d")
.then(res => res.json())
.then(data => { carModelBlueprints3d = data.models || {}; })
.catch(() => {});
function buildCarFromBlueprint3d(blueprint) {
const group = new THREE.Group();
blueprint.forEach(part => {
const prim = BUILDER_PRIMITIVES_RUNTIME.find(p => p.type === part.type);
if (!prim) return;
const partMesh = new THREE.Mesh(prim.geo(), new THREE.MeshLambertMaterial({ color: part.color || 0x3a5aa0 }));
partMesh.position.set(part.position.x, part.position.y, part.position.z);
partMesh.scale.set(part.scale.x, part.scale.y, part.scale.z);
partMesh.rotation.set(part.rotation._x, part.rotation._y, part.rotation._z);
partMesh.castShadow = true;
group.add(partMesh);
});
group.userData.hasCustomModel = true;
return group;
}
function createCarMesh(model) {
// 1. Priorität: selbstgebautes Fahrzeug-Modell für dieses model-Feld
if (model && carModelBlueprints3d[model]) {
return buildCarFromBlueprint3d(carModelBlueprints3d[model]);
}
// 2. Priorität: geladenes Platzhalter-Truck-Modell
if (preloadedModels.truck) {
const clone = preloadedModels.truck.scene.clone();
clone.scale.setScalar(preloadedModels.truck.scale);
clone.rotation.y = TRUCK_ROTATION_OFFSET_DEG * Math.PI / 180;
clone.traverse(o => { if (o.isMesh) o.castShadow = true; });
const group = new THREE.Group();
group.add(clone);
return group;
}
// 3. Fallback, falls noch gar nichts geladen werden konnte
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(1.0, 0.5, 2.0),
new THREE.MeshLambertMaterial({ color: 0x3a5aa0 })
);
body.position.y = 0.35;
body.castShadow = true;
group.add(body);
// kleine "Front"-Markierung, damit man die Ausrichtung erkennt
const front = new THREE.Mesh(
new THREE.BoxGeometry(0.7, 0.3, 0.3),
new THREE.MeshLambertMaterial({ color: 0x1a2a55 })
);
front.position.set(0, 0.4, -1.0);
group.add(front);
return group;
}
function updateCarsRendering(carsList) {
const seenIds = new Set();
carsList.forEach(c => {
seenIds.add(c.id);
let mesh = carMeshes.get(c.id);
if (!mesh) {
mesh = createCarMesh(c.model);
scene.add(mesh);
carMeshes.set(c.id, mesh);
// Blaulicht - Licht ist immer da, wird nur bei emergency
// sichtbar/aktiv geschaltet (siehe updateSirens3d)
const beacon = new THREE.PointLight(0x2255ff, 0, 6);
beacon.position.set(0, 0.8, 0);
mesh.add(beacon);
mesh.userData.beaconLight = beacon;
}
// Server-Winkel ist in Grad, Y-Rotation in Three.js in Radiant
mesh.position.set(c.x / 32, 0, c.y / 32);
mesh.rotation.y = -(c.angle || 0) * Math.PI / 180;
mesh.visible = myWorld ? c.world === myWorld : true;
mesh.userData.emergency = !!c.emergency;
// Wank-Effekt: Ziel-Neigung anhand der Winkeländerung seit dem
// letzten Update berechnen (schnelle Kurve = stärkere Neigung),
// tatsächlich angewendet wird sie sanft interpoliert (siehe
// animate()), damit es nicht ruckartig kippt
const angle = c.angle || 0;
if (mesh.userData.lastCarAngle !== undefined) {
let delta = angle - mesh.userData.lastCarAngle;
while (delta > 180) delta -= 360;
while (delta < -180) delta += 360;
const speedFactor = Math.min(1, Math.abs(c.speedKmh || 0) / 60);
mesh.userData.targetRoll = Math.max(-0.22, Math.min(0.22, -delta * 0.03 * speedFactor));
}
mesh.userData.lastCarAngle = angle;
});
for (const [id, mesh] of carMeshes) {
if (!seenIds.has(id)) {
scene.remove(mesh);
carMeshes.delete(id);
}
}
}
const editorObjectsGroup = new THREE.Group();
scene.add(editorObjectsGroup);
let editorColliders = []; // { x, z, radius } - für einfache Kreis-Kollision
let waterMeshes3d = []; // Materialien aller Wasser-Objekte, für den Schimmer-Effekt
let lampLights3d = []; // PointLights aller Laternen, für das Nacht-Leuchten
let windowGlowMats3d = []; // Materialien aller glow:true Bauplan-Teile (z.B. Fenster)
let elevatedPlatforms3d = []; // { minX, maxX, minZ, maxZ, height } - Brücken, für Fallschaden
let waterZones3d = []; // { minX, maxX, minZ, maxZ } - für Schwimm-Erkennung
let portalLinks3d = []; // { type, portalId, linkedTo, x, z } - für Portal-/Fahrstuhl-Teleport
const BUILDER_PRIMITIVES_RUNTIME = [
{ type: "cube", geo: () => new THREE.BoxGeometry(1, 1, 1) },
{ type: "sphere", geo: () => new THREE.SphereGeometry(0.6, 14, 14) },
{ type: "cylinder", geo: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 14) },
{ type: "cone", geo: () => new THREE.ConeGeometry(0.6, 1.2, 14) }
];
// Muss zu den Grundformen in editor3d.html passen (Farbe/Form je Typ)
const EDITOR_PALETTE_RUNTIME = {
house: { color: 0xc0784a, geo: () => new THREE.BoxGeometry(2, 2, 2), collisionRadius: 1.3 },
tree: { color: 0x2e7d32, geo: () => new THREE.ConeGeometry(0.8, 2, 8), collisionRadius: 0.6 },
lamp: { color: 0xd4c060, geo: () => new THREE.CylinderGeometry(0.1, 0.1, 2.2, 8), collisionRadius: 0.15 },
bush: { color: 0x4caf50, geo: () => new THREE.SphereGeometry(0.6, 10, 10), collisionRadius: 0.6 },
wall: { color: 0x8a8a8a, geo: () => new THREE.BoxGeometry(2, 1.2, 0.3), collisionRadius: 1.1 },
car: { color: 0x3a5aa0, geo: () => new THREE.BoxGeometry(1.8, 0.7, 0.9), collisionRadius: 1.0 },
barrier: { color: 0xd47a2c, geo: () => new THREE.BoxGeometry(1.2, 0.8, 0.15), collisionRadius: 0.7 },
water: { color: 0x2e6fa0, geo: () => new THREE.BoxGeometry(3, 0.05, 3) }, // kein collisionRadius - durchlaufbar (nur optisch)
portal: { color: 0xf0c419, geo: () => new THREE.TorusGeometry(0.9, 0.15, 12, 24), rotateX: true }, // kein collisionRadius - man soll ja durchgehen können
signpost: { color: 0x8b5a2b, geo: () => new THREE.BoxGeometry(0.08, 1.6, 0.08), collisionRadius: 0.15 },
graffiti: { color: 0x333333, geo: () => new THREE.BoxGeometry(2.2, 1.4, 0.05) } // keine Kollision - Wandbemalung
};
async function loadEditorObjectsForWorld(world) {
while (editorObjectsGroup.children.length) editorObjectsGroup.remove(editorObjectsGroup.children[0]);
editorColliders = [];
waterMeshes3d = [];
lampLights3d = [];
windowGlowMats3d = [];
elevatedPlatforms3d = [];
waterZones3d = [];
portalLinks3d = []; // { type, portalId, linkedTo, x, z } - für Portal-/Fahrstuhl-Teleport
try {
const res = await fetch(`/api/get_3d_objects?world=${encodeURIComponent(world)}`);
const data = await res.json();
console.log(`[3D-Objekte] Antwort für Welt "${world}":`, data.ok ? `${data.objects.length} Objekt(e) gefunden` : `Fehler: ${data.error}`);
if (!data.ok) return;
data.objects.forEach(row => {
try {
let mesh;
if (row.type.startsWith("custom_") && row.blueprint) {
mesh = new THREE.Group();
// MySQL liefert JSON-Spalten über den Treiber schon als
// fertiges Objekt zurück (nicht als Text) - JSON.parse
// nur anwenden, falls es tatsächlich noch ein String ist
const blueprint = typeof row.blueprint === "string" ? JSON.parse(row.blueprint) : row.blueprint;
blueprint.forEach(part => {
const prim = BUILDER_PRIMITIVES_RUNTIME.find(p => p.type === part.type);
if (!prim) return;
// Teile mit glow:true (z.B. Fenster) leuchten nachts warm
// gelb - Emissive-Intensität wird in
// applyDayNightAndWeather3d() umgeschaltet
const mat = part.glow
? new THREE.MeshStandardMaterial({ color: part.color || 0x5b8def, emissive: 0xffd580, emissiveIntensity: 0 })
: new THREE.MeshLambertMaterial({ color: part.color || 0x5b8def });
if (part.glow) windowGlowMats3d.push(mat);
const partMesh = new THREE.Mesh(prim.geo(), mat);
partMesh.position.set(part.position.x, part.position.y, part.position.z);
partMesh.scale.set(part.scale.x, part.scale.y, part.scale.z);
partMesh.rotation.set(part.rotation._x, part.rotation._y, part.rotation._z);
partMesh.castShadow = true;
mesh.add(partMesh);
});
} else {
const def = EDITOR_PALETTE_RUNTIME[row.type];
if (!def) return; // unbekannter/gelöschter Typ - überspringen statt abzustürzen
const mat = new THREE.MeshLambertMaterial({ color: def.color });
mesh = new THREE.Mesh(def.geo(), mat);
if (def.rotateX) mesh.rotation.x = Math.PI / 2;
mesh.castShadow = true;
if (row.type === "water") {
mat.transparent = true;
waterMeshes3d.push(mat);
}
if (row.type === "lamp") {
const light = new THREE.PointLight(0xffdd88, 0, 4); // Intensität 0 = anfangs aus
light.position.set(0, 0.9, 0); // relativ zum Laternen-Mesh (oben an der Stange)
mesh.add(light);
lampLights3d.push(light);
}
if (row.type === "signpost" && row.label_text) {
const textSprite = makeTextSprite3d(row.label_text);
textSprite.position.y = 1.0;
mesh.add(textSprite);
}
if (row.type === "graffiti" && row.label_text) {
// Format "farbe|text" - siehe editor3d.html
const [color, ...rest] = row.label_text.split("|");
paintGraffitiTexture3d(mesh, rest.join("|"), color);
}
}
mesh.position.set(Number(row.x), Number(row.y), Number(row.z));
mesh.rotation.y = Number(row.rotation_y) || 0;
const s = Number(row.scale) || 1;
mesh.scale.multiplyScalar(s);
editorObjectsGroup.add(mesh);
// Wasser-Bereich merken (XZ-Ebene) - fürs Schwimmen: langsamere
// Bewegung + Schwimm-Animation, solange man innerhalb steht
if (row.type === "water") {
const box = new THREE.Box3().setFromObject(mesh);
waterZones3d.push({
minX: box.min.x, maxX: box.max.x,
minZ: box.min.z, maxZ: box.max.z
});
}
// Portal/Fahrstuhl merken - beide nutzen dieselbe portalId/
// linkedTo-Verknüpfung aus dem Editor
if ((row.type === "portal" || row.type === "elevator") && row.portal_id) {
portalLinks3d.push({
type: row.type,
portalId: row.portal_id,
linkedTo: row.linked_to,
x: mesh.position.x, z: mesh.position.z
});
}
// Kollisionsdaten merken: einfache Kreis-Kollision in der
// XZ-Ebene (Höhe/Y spielt keine Rolle - man kann nicht
// "drunter durchlaufen"). Portale/Wasser haben bewusst
// keinen Radius definiert -> bleiben durchlaufbar.
let radius = null;
if (row.type === "custom_bridge" || row.type === "custom_tunnel" || row.type === "custom_pier" || row.type === "custom_sportscourt") {
// Bewusst KEINE Kollision - diese Objekte sind zum
// Durchfahren/-laufen gedacht, nicht zum Blockieren. Die
// automatische Bounding-Box-Kollision würde sie sonst
// komplett unpassierbar machen (siehe Kommentar unten)
// Brücken: Höhe + XZ-Bereich merken, für Fallschaden beim
// Herunterlaufen (Stege sind mit 0,35 zu niedrig, um
// relevant zu sein - nur Brücken tragen bei)
if (row.type === "custom_bridge") {
const box = new THREE.Box3().setFromObject(mesh);
elevatedPlatforms3d.push({
minX: box.min.x, maxX: box.max.x,
minZ: box.min.z, maxZ: box.max.z,
height: box.max.y
});
}
} else if (row.type.startsWith("custom_")) {
// Für selbstgebaute Objekte: Radius aus der tatsächlichen
// Bounding-Box berechnen, da es keinen festen Wert gibt
const box = new THREE.Box3().setFromObject(mesh);
const size = box.getSize(new THREE.Vector3());
radius = Math.max(size.x, size.z) / 2;
} else {
const def = EDITOR_PALETTE_RUNTIME[row.type];
radius = def?.collisionRadius ?? null;
}
if (radius) {
editorColliders.push({ x: mesh.position.x, z: mesh.position.z, radius: radius * s });
}
} catch (err) {
// Ein fehlerhaftes Objekt (z.B. kaputter Bauplan) darf nicht
// alle nachfolgenden Objekte in derselben Ladung mit
// verhindern - überspringen und im Log sichtbar machen
console.error(`[3D-Objekte] Objekt #${row.id} (Typ "${row.type}") konnte nicht geladen werden:`, err);
}
});
statusLine.textContent += ` · ${data.objects.length} Editor-Objekte geladen`;
} catch {
// still scheitern lassen - der 3D-Testclient soll auch ohne
// Editor-Objekte weiter benutzbar bleiben
}
}
// -------------------------------------------------------------
// SPIELER ALS EINFACHE PLATZHALTER-FIGUREN (Kapsel-Körper + Kugel-Kopf)
// -------------------------------------------------------------
// Spielfiguren bekommen bewusst KEIN CesiumMan-Modell (das ist eine
// einzelne feste Form ohne Einzelteile, kann also keine Kleidung
// zeigen) - stattdessen eine segmentierte Figur mit einzeln
// einfärbbaren Teilen (Kopf/Oberteil/Beine/Füße/Hut), die exakt die
// Kleidungsfarben aus dem State-Update widerspiegelt
function createPlayerMesh(color) {
const group = new THREE.Group();
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 12, 12),
new THREE.MeshLambertMaterial({ color: 0xf0c090 })
);
head.position.y = 1.5;
head.castShadow = true;
group.add(head);
// Hut/Helm - standardmäßig unsichtbar, wird nur bei getragenem
// Helm eingeblendet (siehe applyClothingAppearance3d)
const hat = new THREE.Mesh(
new THREE.CylinderGeometry(0.19, 0.19, 0.14, 12),
new THREE.MeshLambertMaterial({ color: 0x333333 })
);
hat.position.y = 1.68;
hat.visible = false;
group.add(hat);
const torso = new THREE.Mesh(
new THREE.BoxGeometry(0.42, 0.55, 0.24),
new THREE.MeshLambertMaterial({ color })
);
torso.position.y = 1.05;
torso.castShadow = true;
group.add(torso);
// Beine/Füße getrennt links/rechts, jeweils um einen eigenen
// "Hüft"-Punkt (0.78) drehbar - macht eine echte Schrittbewegung
// möglich (siehe updateWalkAnimations3d)
const legMat = new THREE.MeshLambertMaterial({ color: 0x2c3e50 });
const footMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });
const HIP_Y = 0.78;
function makeLeg(xOffset) {
const pivot = new THREE.Group();
pivot.position.set(xOffset, HIP_Y, 0);
group.add(pivot);
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.17, 0.5, 0.2), legMat);
leg.position.y = -0.25;
leg.castShadow = true;
pivot.add(leg);
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.1, 0.26), footMat);
foot.position.set(0, -0.55, 0.05);
foot.castShadow = true;
pivot.add(foot);
return { pivot, leg, foot };
}
const legLeft = makeLeg(-0.1);
const legRight = makeLeg(0.1);
// Arme - eigener Schulter-Drehpunkt (analog zu den Beinen), Farbe
// folgt dem Oberteil (Ärmel), Hand etwas dunkler angedeutet
const armMat = new THREE.MeshLambertMaterial({ color });
const handMat = new THREE.MeshLambertMaterial({ color: 0xf0c090 });
const SHOULDER_Y = 1.28;
function makeArm(xOffset) {
const pivot = new THREE.Group();
pivot.position.set(xOffset, SHOULDER_Y, 0);
group.add(pivot);
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.48, 0.15), armMat);
arm.position.y = -0.24;
arm.castShadow = true;
pivot.add(arm);
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 8), handMat);
hand.position.y = -0.5;
hand.castShadow = true;
pivot.add(hand);
return { pivot, arm };
}
const armLeft = makeArm(-0.29);
const armRight = makeArm(0.29);
// Referenzen merken, damit die Farben später (Kleidung wechseln)
// aktualisiert werden können, ohne die ganze Figur neu zu bauen
group.userData.parts = {
head, hat, torso,
legs: legLeft.leg, feet: legLeft.foot, // Farb-Updates reichen auf EIN Bein/einen Fuß, das andere spiegeln wir mit
legRightMesh: legRight.leg, footRightMesh: legRight.foot,
legPivotLeft: legLeft.pivot, legPivotRight: legRight.pivot,
armLeftMesh: armLeft.arm, armRightMesh: armRight.arm,
armPivotLeft: armLeft.pivot, armPivotRight: armRight.pivot
};
return group;
}
// Wendet die aktuellen Kleidungsfarben aus einem State-Update auf eine
// bestehende Spielfigur an (Oberteil/Hose/Schuhe-Farbe, Hut sichtbar
// nur bei getragenem Helm) - wird bei JEDEM State-Update aufgerufen,
// damit ein Kleiderwechsel sofort sichtbar wird
// -------------------------------------------------------------
// GRUPPEN-MARKER: farbiger Ring am Boden für Fahrer+Mitfahrer
// desselben Autos - macht auf einen Blick sichtbar, wer gerade
// zusammen unterwegs ist. Farbe wird stabil aus der Auto-ID
// abgeleitet (dieselbe Fahrt = immer dieselbe Farbe)
// -------------------------------------------------------------
const GROUP_COLORS_3D = [0xe74c3c, 0x2ecc71, 0x3498db, 0xf39c12, 0x9b59b6, 0x1abc9c, 0xe84393, 0x795548];
function getGroupColorForCar3d(carId) {
let hash = 0;
const s = String(carId);
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
return GROUP_COLORS_3D[hash % GROUP_COLORS_3D.length];
}
function ensureGroupRing3d(mesh) {
if (mesh.userData.groupRing) return mesh.userData.groupRing;
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.5, 0.65, 24),
new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.85 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.05;
ring.visible = false;
mesh.add(ring);
mesh.userData.groupRing = ring;
return ring;
}
function updateGroupMarkers3d(carsList) {
// playerId -> Farbe, nur für Autos mit Fahrer UND Mitfahrer gleichzeitig
const colorByPlayerId = new Map();
carsList.forEach(c => {
if (c.driverId && c.passengerId) {
const color = getGroupColorForCar3d(c.id);
colorByPlayerId.set(c.driverId, color);
colorByPlayerId.set(c.passengerId, color);
}
});
const myRing = ensureGroupRing3d(myMesh);
if (colorByPlayerId.has(myPlayerId)) {
myRing.material.color.setHex(colorByPlayerId.get(myPlayerId));
myRing.visible = true;
} else {
myRing.visible = false;
}
for (const [id, mesh] of otherMeshes) {
const ring = ensureGroupRing3d(mesh);
if (colorByPlayerId.has(id)) {
ring.material.color.setHex(colorByPlayerId.get(id));
ring.visible = true;
} else {
ring.visible = false;
}
}
}
function applyClothingAppearance3d(mesh, data) {
const parts = mesh.userData.parts;
if (!parts || !data) return;
if (data.shirtColor) {
parts.torso.material.color.set(data.shirtColor);
parts.armLeftMesh.material.color.set(data.shirtColor);
parts.armRightMesh.material.color.set(data.shirtColor);
}
if (data.pantsColor) {
parts.legs.material.color.set(data.pantsColor);
parts.legRightMesh.material.color.set(data.pantsColor);
}
if (data.shoesColor) {
parts.feet.material.color.set(data.shoesColor);
parts.footRightMesh.material.color.set(data.shoesColor);
}
parts.hat.visible = !!data.helmetImage;
}
let myMesh = createPlayerMesh(0x5b8def);
scene.add(myMesh);
const otherMeshes = new Map(); // playerId -> THREE.Group
let myWorld = null;
let lastLoadedObjectsWorld = null;
let myX = 0, myY = 0; // Server-Koordinaten (px, wie im echten Spiel)
let myRotationY = 0;
// -------------------------------------------------------------
// KAMERA: einfache Drittperson-Verfolgung, per Maus drehbar
// -------------------------------------------------------------
// Empfindlichkeits-Multiplikator für Maus/Touch-Kamerasteuerung -
// wird auf die Basis-Werte (0.005/0.006) multipliziert, aus
// localStorage geladen, damit die Wahl über Sitzungen hinweg bleibt
let mouseSensitivity3d = parseFloat(localStorage.getItem("mouseSensitivity3d")) || 1.0;
function toggleSettingsPanel3d() {
const panel = document.getElementById("settingsPanel3d");
const isOpen = panel.style.display === "block";
panel.style.display = isOpen ? "none" : "block";
document.getElementById("mouseSensSlider3d").value = mouseSensitivity3d;
document.getElementById("mouseSensValue3d").textContent = mouseSensitivity3d.toFixed(1) + "x";
}
function updateMouseSensitivity3d(value) {
mouseSensitivity3d = parseFloat(value);
localStorage.setItem("mouseSensitivity3d", mouseSensitivity3d);
document.getElementById("mouseSensValue3d").textContent = mouseSensitivity3d.toFixed(1) + "x";
}
let camYaw = 0;
let camPitch = 0.5;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
canvas.addEventListener("mousedown", e => { isDragging = true; lastMouseX = e.clientX; lastMouseY = e.clientY; });
window.addEventListener("mouseup", () => { isDragging = false; });
window.addEventListener("mousemove", e => {
if (!isDragging) return;
camYaw -= (e.clientX - lastMouseX) * 0.005 * mouseSensitivity3d;
camPitch = Math.max(0.15, Math.min(1.4, camPitch - (e.clientY - lastMouseY) * 0.005 * mouseSensitivity3d));
lastMouseX = e.clientX;
lastMouseY = e.clientY;
});
let firstPersonView3d = localStorage.getItem("firstPersonView3d") === "true";
// -------------------------------------------------------------
// ZUSCHAUER-MODUS (nur Admins): freie Flugkamera zum Beobachten,
// ohne die eigene Spielfigur zu bewegen oder ins Geschehen
// einzugreifen - eigene Figur bleibt an ihrer letzten echten Position
// stehen und wird ausgeblendet, bis der Modus wieder verlassen wird
// -------------------------------------------------------------
let spectatorMode3d = false;
const spectatorPos3d = new THREE.Vector3();
// -------------------------------------------------------------
// FOTO-MODUS: freie Kamera wie beim Zuschauer-Modus, aber für ALLE
// Spieler nutzbar (kein Admin nötig) und blendet zusätzlich die
// komplette Oberfläche aus - eigene Figur bleibt an Ort und Stelle
// stehen, nur die Kamera fliegt frei zum Fotografieren
// -------------------------------------------------------------
let photoMode3d = false;
const photoModePos3d = new THREE.Vector3();
function togglePhotoMode3d() {
photoMode3d = !photoMode3d;
document.body.classList.toggle("photo-mode3d", photoMode3d);
if (photoMode3d) {
photoModePos3d.copy(camera.position);
}
}
function updatePhotoModeMovement3d(delta) {
const speed = (keys["shift"] ? 20 : 8) * delta;
const forward = new THREE.Vector3(Math.sin(camYaw), 0, Math.cos(camYaw));
const right = new THREE.Vector3(-Math.cos(camYaw), 0, Math.sin(camYaw));
if (keys["w"]) photoModePos3d.addScaledVector(forward, speed);
if (keys["s"]) photoModePos3d.addScaledVector(forward, -speed);
if (keys["a"]) photoModePos3d.addScaledVector(right, -speed);
if (keys["d"]) photoModePos3d.addScaledVector(right, speed);
if (keys["q"]) photoModePos3d.y -= speed;
if (keys["e"]) photoModePos3d.y += speed;
}
function updatePhotoModeCamera3d() {
camera.position.copy(photoModePos3d);
const lookDir = new THREE.Vector3(
Math.sin(camYaw) * Math.cos(camPitch - 0.5),
Math.sin(camPitch - 0.5),
Math.cos(camYaw) * Math.cos(camPitch - 0.5)
);
camera.lookAt(photoModePos3d.clone().add(lookDir));
}
function toggleSpectatorMode3d() {
spectatorMode3d = !spectatorMode3d;
if (spectatorMode3d) {
spectatorPos3d.copy(camera.position);
myMesh.visible = false;
addChatMessage3d("👁️ Zuschauer-Modus aktiv - WASD zum Fliegen, Q/E hoch/runter, /spectate zum Beenden.", "system");
} else {
myMesh.visible = true;
addChatMessage3d("👁️ Zuschauer-Modus beendet.", "system");
}
}
function updateSpectatorMovement3d(delta) {
const speed = (keys["shift"] ? 24 : 10) * delta;
const forward = new THREE.Vector3(Math.sin(camYaw), 0, Math.cos(camYaw));
const right = new THREE.Vector3(-Math.cos(camYaw), 0, Math.sin(camYaw));
if (keys["w"]) spectatorPos3d.addScaledVector(forward, speed);
if (keys["s"]) spectatorPos3d.addScaledVector(forward, -speed);
if (keys["a"]) spectatorPos3d.addScaledVector(right, -speed);
if (keys["d"]) spectatorPos3d.addScaledVector(right, speed);
if (keys["q"]) spectatorPos3d.y -= speed;
if (keys["e"]) spectatorPos3d.y += speed;
}
function updateSpectatorCamera3d() {
camera.position.copy(spectatorPos3d);
const lookDir = new THREE.Vector3(
Math.sin(camYaw) * Math.cos(camPitch - 0.5),
Math.sin(camPitch - 0.5),
Math.cos(camYaw) * Math.cos(camPitch - 0.5)
);
camera.lookAt(spectatorPos3d.clone().add(lookDir));
}
function updateCamera() {
if (firstPersonView3d) {
// Ich-Sicht: Kamera sitzt auf Kopfhöhe der Figur, schaut in die
// per Maus eingestellte Richtung - eigene Figur wird ausgeblendet,
// damit sie nicht die Sicht versperrt
const lookX = myMesh.position.x + Math.sin(camYaw) * Math.cos(camPitch);
const lookY = myMesh.position.y + 1.6 + Math.sin(camPitch);
const lookZ = myMesh.position.z + Math.cos(camYaw) * Math.cos(camPitch);
camera.position.set(myMesh.position.x, myMesh.position.y + 1.6, myMesh.position.z);
camera.lookAt(lookX, lookY, lookZ);
return;
}
const dist = 6;
// Kamera muss HINTER der Laufrichtung sitzen (nicht davor!), sonst
// läuft man beim Drücken von W optisch auf die Kamera zu statt von
// ihr weg - das sah wie vertauschtes Vorwärts/Rückwärts aus
const camX = myMesh.position.x - Math.sin(camYaw) * dist * Math.cos(camPitch);
const camZ = myMesh.position.z - Math.cos(camYaw) * dist * Math.cos(camPitch);
const camY = myMesh.position.y + 1.5 + dist * Math.sin(camPitch);
camera.position.set(camX, camY, camZ);
camera.lookAt(myMesh.position.x, myMesh.position.y + 1, myMesh.position.z);
}
function toggleFirstPersonView3d() {
firstPersonView3d = !firstPersonView3d;
localStorage.setItem("firstPersonView3d", firstPersonView3d ? "true" : "false");
myMesh.visible = !firstPersonView3d && !drivingCarId && !passengerCarId;
}
// -------------------------------------------------------------
// WEBSOCKET-VERBINDUNG (gleiches Protokoll wie das echte Spiel -
// der Server merkt nicht, dass hier ein anderer Client dranhängt)
// -------------------------------------------------------------
let ws = null;
let myPlayerId = null;
let myIsAdmin = false;
let myAdminDuty = false;
const statusLine = document.getElementById("statusLine");
// -------------------------------------------------------------
// HUD, INVENTAR, CHAT
// -------------------------------------------------------------
let itemInfoCache3d = {};
let myInventory3d = [];
let clothingCatalogFlat3d = {}; // id -> { id, slot, name, color, price }
fetch("/api/clothing_catalog")
.then(res => res.json())
.then(data => { (data.items || []).forEach(i => { clothingCatalogFlat3d[i.id] = i; }); })
.catch(() => {});
let clothingCatalogCache3d = {};
let currentHouseId3d = null;
fetch("/api/items")
.then(res => res.json())
.then(data => {
(data.items || []).forEach(i => { itemInfoCache3d[i.id] = i; });
})
.catch(() => {});
function updateHud(me) {
document.getElementById("hudMoney").textContent = me.money + "$";
document.getElementById("hudBank").textContent = me.bank + "$";
document.getElementById("hudHealth").textContent = Math.round(me.health);
document.getElementById("hudHunger").textContent = Math.round(me.hunger);
document.getElementById("hudThirst").textContent = Math.round(me.thirst);
}
function updateInventoryPanel(inventory) {
const list = document.getElementById("inventoryList3d");
if (inventory.length === 0) {
list.innerHTML = `<p style="color:#888;">Leer</p>`;
return;
}
list.innerHTML = inventory.map(item => {
const clothingMatch = String(item.id).match(/^clothing_(\d+)$/);
if (clothingMatch) {
const catalogItem = findClothingCatalogItem3d(Number(clothingMatch[1]));
const label = catalogItem ? "👕 " + catalogItem.name : item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="wearClothing3d('${item.id}')">Anziehen</button></div>`;
}
const info = itemInfoCache3d[item.id];
const label = info ? info.name : item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="useItem3d('${item.id}')">Benutzen</button></div>`;
}).join("");
}
function useItem3d(itemId) {
ws.send(JSON.stringify({ type: "use_item", itemId }));
}
function findClothingCatalogItem3d(id) {
return clothingCatalogFlat3d[id] || null;
}
function wearClothing3d(invItemId) {
ws.send(JSON.stringify({ type: "wear_clothing", invItemId }));
}
function sendChat3d() {
const input = document.getElementById("chatInput3d");
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
input.value = "";
const cmd = text.startsWith("/") ? text.slice(1).toLowerCase() : null;
const raw = text.startsWith("/") ? text.slice(1) : "";
if (cmd === "ticket" || cmd === "tickets") {
closeAllPanels3d();
document.getElementById("ticketPanel3d").style.display = "block";
loadMyTickets3d();
return;
}
if (cmd === "leitstelle") {
closeAllPanels3d();
document.getElementById("leitstellePanel3d").style.display = "block";
ws.send(JSON.stringify({ type: "leitstelle_open" }));
return;
}
if (cmd === "börse" || cmd === "boerse" || cmd === "stocks") {
closeAllPanels3d();
document.getElementById("stockPanel3d").style.display = "block";
showStockTab3d("list");
return;
}
if (cmd === "verkaufen" || cmd === "hehler") {
ws.send(JSON.stringify({ type: "sell_stolen_car" }));
return;
}
if (cmd === "hotwire") {
ws.send(JSON.stringify({ type: "commit_crime", crimeType: "car_theft" }));
return;
}
if (cmd === "angeln") {
if (!isNearWater3d(myX, myY, 2.5)) {
addChatMessage3d("Du musst in der Nähe von Wasser sein, um zu angeln.", "system");
return;
}
ws.send(JSON.stringify({ type: "fishing_start" }));
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) { addChatMessage3d("Nutzung: /gang create Name Tag", "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") { ws.send(JSON.stringify({ type: "gang_invite", username: rest.join(" ") })); return; }
if (sub === "accept") { ws.send(JSON.stringify({ type: "gang_accept", gangName: rest.join(" ") })); return; }
if (sub === "kick") { ws.send(JSON.stringify({ type: "gang_kick", username: rest.join(" ") })); return; }
if (sub === "leave") { ws.send(JSON.stringify({ type: "gang_leave" })); return; }
if (sub === "deposit") { ws.send(JSON.stringify({ type: "gang_deposit", amount: Number(rest[0]) })); return; }
if (sub === "withdraw") { ws.send(JSON.stringify({ type: "gang_withdraw", amount: Number(rest[0]) })); return; }
if (sub === "info") { ws.send(JSON.stringify({ type: "gang_info" })); return; }
addChatMessage3d("Unbekannter /gang Befehl.", "system");
return;
}
if (cmd === "gate claim") {
ws.send(JSON.stringify({ type: "gate_claim" }));
return;
}
if (["wave", "dance", "point", "sit", "salute"].includes(cmd)) {
ws.send(JSON.stringify({ type: "emote", emoteType: cmd }));
return;
}
if (cmd === "roadblock" || cmd === "sperre") {
ws.send(JSON.stringify({ type: "roadblock_place" }));
return;
}
if (cmd === "roadblock remove" || cmd === "sperre entfernen") {
ws.send(JSON.stringify({ type: "roadblock_remove" }));
return;
}
if (cmd === "help") {
showHelp3d();
return;
}
if (cmd.startsWith("setpoint")) {
handleSetpointCommand3d(raw);
return;
}
if (cmd === "admin duty") {
if (!myIsAdmin) {
addChatMessage3d("Kein Admin-Zugriff.", "system");
return;
}
ws.send(JSON.stringify({ type: "admin_toggle_duty" }));
return;
}
if (cmd === "spectate") {
if (!myIsAdmin) {
addChatMessage3d("Kein Admin-Zugriff.", "system");
return;
}
toggleSpectatorMode3d();
return;
}
ws.send(JSON.stringify({ type: "chat_send", text }));
}
document.getElementById("chatInput3d").addEventListener("keydown", e => {
if (e.key === "Enter") sendChat3d();
e.stopPropagation(); // verhindert, dass WASD beim Tippen die Spielfigur bewegt
});
let reconnectAttempts3d = 0;
let reconnectTimeoutId3d = null;
let pingIntervalId3d = null;
let authBlocked3d = false; // true bei auth_error (z.B. Beta-Sperre) - erneutes Verbinden bringt nichts
// Steigende Wartezeit zwischen Versuchen (2s, 4s, 6s... max. 15s),
// damit ein länger down liegender Server nicht mit Anfragen geflutet wird
function scheduleReconnect3d() {
if (authBlocked3d) return; // kein Sinn, es nochmal zu versuchen
document.getElementById("reconnectOverlay3d").style.display = "flex";
clearTimeout(reconnectTimeoutId3d);
reconnectAttempts3d++;
const delay = Math.min(15000, 2000 * reconnectAttempts3d);
document.getElementById("reconnectText3d").textContent =
`Verbindung unterbrochen - nächster Versuch in ${Math.round(delay / 1000)}s (Versuch ${reconnectAttempts3d})...`;
reconnectTimeoutId3d = setTimeout(connect, delay);
}
function connect() {
ws = new WebSocket("wss://server.borderville.de");
ws.onopen = () => {
statusLine.textContent = "Verbunden, authentifiziere...";
ws.send(JSON.stringify({ type: "auth", token }));
reconnectAttempts3d = 0;
document.getElementById("reconnectOverlay3d").style.display = "none";
clearInterval(pingIntervalId3d);
pingIntervalId3d = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", t: Date.now() }));
}
}, 5000);
};
ws.onclose = () => {
statusLine.textContent = "❌ Verbindung getrennt.";
clearInterval(pingIntervalId3d);
scheduleReconnect3d();
};
ws.onerror = () => {
statusLine.textContent = "❌ Verbindungsfehler.";
};
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.type === "pong") {
const latency = Date.now() - msg.t;
const el = document.getElementById("pingIndicator3d");
document.getElementById("pingText3d").textContent = latency + " ms";
el.style.color = latency < 100 ? "#6fd98a" : latency < 300 ? "#f0c419" : "#e06c6c";
}
if (msg.type === "auth_ok") {
myPlayerId = msg.id;
myIsAdmin = !!msg.isAdmin;
// Nur Beta-Tester und Admins dürfen ins 3D-Spiel - alle
// anderen werden sofort zurück zur Startseite geschickt
if (!msg.isBetaTester && !myIsAdmin) {
statusLine.textContent = "❌ Das 3D-Spiel ist aktuell nur für Beta-Tester zugänglich.";
ws.close();
setTimeout(() => { location.href = "/index.html?betaonly=1"; }, 1500);
return;
}
statusLine.textContent = `✅ Verbunden als ${msg.username} (3D-Testmodus)`;
ws.send(JSON.stringify({ type: "phone_get_contacts" })); // für Freund-Marker auf der Minimap
}
if (msg.type === "auth_error") {
authBlocked3d = true;
statusLine.textContent = "❌ " + msg.msg;
document.getElementById("reconnectOverlay3d").style.display = "none";
alert(msg.msg);
}
if (msg.type === "admin_duty_status") {
myAdminDuty = msg.onDuty;
document.getElementById("statusLine").textContent =
statusLine.textContent.replace(/ · 🛡️.*$/, "") + (myAdminDuty ? " · 🛡️ Admin-Dienst aktiv" : "");
}
if (msg.type === "world_info") {
gameHour3d = msg.hour;
weather3d = msg.weather;
applyDayNightAndWeather3d();
}
if (msg.type === "npc_dialog") {
addChatMessage3d("NPC: " + msg.text, "system");
}
if (msg.type === "close_window") {
const panelMap = {
shopWindow: "shopPanel3d", atmBox: "atmPanel3d", garageBox: "garagePanel3d",
clothingBox: "clothingPanel3d", trailerShopBox: "trailerShopPanel3d", jobBox: "jobMenuPanel3d"
};
const el = document.getElementById(panelMap[msg.windowType] || msg.windowType);
if (el) el.style.display = "none";
if (msg.reason === "cuffed") {
addChatMessage3d("🔗 Fenster geschlossen - du wurdest gefesselt.", "system");
}
}
if (msg.type === "event_update") {
renderEventBanner3d(msg.events || []);
}
if (msg.type === "dealer_update") {
buildPoiMarkers({ ...lastMapData3d, dealerSpots: msg.spots || [] });
}
if (msg.type === "zone_update") {
buildPoiMarkers({ ...lastMapData3d, territoryZones: msg.zones || [] });
}
if (msg.type === "gate_toggle") {
const entry = mapObjectMeshes3d[msg.objectIndex];
if (entry) {
entry.data.open = msg.open;
entry.mesh.visible = !msg.open;
}
}
if (msg.type === "chat_message") {
const div = document.createElement("div");
div.style.color = msg.system ? "#e0a83a" : "#ddd";
div.textContent = msg.text;
const box = document.getElementById("chatMessages3d");
box.appendChild(div);
box.scrollTop = box.scrollHeight;
if (!msg.system && msg.id !== undefined) {
const targetMesh = msg.id === myPlayerId ? myMesh : otherMeshes.get(msg.id);
if (targetMesh) showChatBubble3d(targetMesh, msg.text);
}
}
if (msg.type === "radio_message") {
const div = document.createElement("div");
div.style.color = "#6fbf9f";
div.textContent = `${msg.icon || "📻"} [${msg.jobName || "Funk"}] ${msg.username}: ${msg.text}`;
const box = document.getElementById("chatMessages3d");
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
if (msg.type === "crime_alert") {
addChatMessage3d(`🚨 ${msg.crimeType} gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "medic_alert") {
addChatMessage3d(`⛑️ ${msg.crimeType} gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "fire_alert") {
addChatMessage3d(`🔥 Feuer gemeldet in "${msg.world}" bei (${msg.x}, ${msg.y})!`, "system");
}
if (msg.type === "server_restart_now") {
addChatMessage3d("🔄 Server wird jetzt neu gestartet - du wirst zur Startseite weitergeleitet...", "system");
setTimeout(() => { location.href = "/index.html?restarted=1"; }, 1200);
}
if (msg.type === "player_died") {
addChatMessage3d(msg.killerName ? `💀 Du wurdest von ${msg.killerName} getötet.` : "💀 Du bist gestorben.", "system");
}
if (msg.type === "jail_status") {
if (msg.jailedUntil && msg.jailedUntil > Date.now()) {
const seconds = Math.ceil((msg.jailedUntil - Date.now()) / 1000);
addChatMessage3d(`🔒 In Haft - noch ${seconds}s.`, "system");
}
}
// -------------------------------------------------------
// SHOP
// -------------------------------------------------------
if (msg.type === "shop_open") {
closeAllPanels3d();
currentShopIsOwner3d = !!msg.isOwner;
const list = document.getElementById("shopItems3d");
list.innerHTML = "";
if (msg.isOwner) {
list.innerHTML = `<p style="color:#7fb0ec; font-size:12px;">🏪 Das ist dein Shop.</p>`;
} else if (!msg.ownerId && msg.purchasePrice) {
list.innerHTML = `<p style="color:#e0a83a; font-size:12px;">🏪 Unverkauft - kostet ${msg.purchasePrice}$ (Chat: /shop buy)</p>`;
}
msg.items.forEach(item => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `<span>${item.name} - ${item.price}$</span><button onclick="buyItem3d('${item.id}')">Kaufen</button>`;
list.appendChild(row);
});
document.getElementById("shopPanel3d").style.display = "block";
}
// -------------------------------------------------------
// BANKAUTOMAT
// -------------------------------------------------------
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") {
addChatMessage3d("Falsche PIN!", "system");
}
if (msg.type === "bank_pin_set_result") {
const el = document.getElementById("atmPinMsg3d");
el.textContent = msg.msg;
el.style.color = msg.ok ? "#6fbf73" : "#e06c6c";
if (msg.ok) {
document.getElementById("atmOldPin3d").value = "";
document.getElementById("atmNewPin3d").value = "";
}
}
if (msg.type === "bank_open") {
closeAllPanels3d();
document.getElementById("atmMoney3d").textContent = msg.money + "$";
document.getElementById("atmBank3d").textContent = msg.bank + "$";
document.getElementById("atmMsg3d").textContent = "";
document.getElementById("atmPinMsg3d").textContent = "";
document.getElementById("atmPanel3d").style.display = "block";
}
if (msg.type === "bank_update") {
document.getElementById("atmMoney3d").textContent = msg.money + "$";
document.getElementById("atmBank3d").textContent = msg.bank + "$";
}
// -------------------------------------------------------
// GARAGE
// -------------------------------------------------------
if (msg.type === "job_menu_data") {
renderJobMenu3d(msg);
}
if (msg.type === "jobcenter_error") {
addChatMessage3d(msg.msg, "system");
}
// Tablet
if (msg.type === "tablet_person_result") renderTabletSearchResult3d(msg);
if (msg.type === "tablet_wanted_list_result") renderTabletWantedList3d(msg.wanted);
if (msg.type === "tablet_plate_result") renderTabletPlateResult3d(msg);
// Handy
if (msg.type === "phone_contacts_data") {
phoneContacts3d = msg.contacts || [];
phonePending3d = msg.pendingRequests || [];
renderPhoneContacts3d();
}
if (msg.type === "incoming_call") {
showIncomingCall3d(msg.callId, msg.callerName);
}
if (msg.type === "call_ringing") {
document.getElementById("outgoingCallText3d").textContent = `📞 Rufe ${msg.username} an...`;
document.getElementById("outgoingCallBox3d").style.display = "block";
}
if (msg.type === "call_connected") {
connectToCallRoom3d(msg);
}
if (msg.type === "call_declined") {
document.getElementById("outgoingCallBox3d").style.display = "none";
addChatMessage3d("Anruf wurde abgelehnt.", "system");
}
if (msg.type === "call_missed") {
document.getElementById("outgoingCallBox3d").style.display = "none";
addChatMessage3d(`Verpasster Anruf bei ${msg.username} (keine Antwort).`, "system");
}
if (msg.type === "call_cancelled") {
hideIncomingCall3d();
document.getElementById("outgoingCallBox3d").style.display = "none";
}
if (msg.type === "call_ended") {
teardownCallRoom3d(false);
addChatMessage3d("Anruf beendet.", "system");
}
if (msg.type === "phone_sms_sent" || msg.type === "phone_sms_received") {
if (msg.type === "phone_sms_received") addChatMessage3d(`📩 SMS von ${msg.username}: ${msg.text}`, "system");
if (currentSmsContact3d === msg.username) {
ws.send(JSON.stringify({ type: "phone_get_messages", username: msg.username }));
}
}
if (msg.type === "phone_messages_data" && currentSmsContact3d === msg.username) {
renderSmsThread3d(msg.messages || []);
}
if (msg.type === "clothing_shop_open") {
closeAllPanels3d();
clothingCatalogCache3d = msg.catalog || {};
["shirt", "pants", "shoes", "helmet"].forEach(slot => renderClothingCatalog3d(slot, clothingCatalogCache3d[slot] || []));
document.getElementById("clothingPanel3d").style.display = "block";
}
if (msg.type === "house_open") {
closeAllPanels3d();
currentHouseId3d = msg.houseId;
document.getElementById("houseName3d").textContent = msg.name;
["houseBuySection3d", "houseStorageSection3d", "houseLockedSection3d", "houseForRentSection3d"].forEach(id => {
document.getElementById(id).style.display = "none";
});
if (!msg.owned) {
document.getElementById("houseBuySection3d").style.display = "block";
document.getElementById("housePrice3d").textContent = msg.price + "$";
} else if (msg.hasKey) {
document.getElementById("houseStorageSection3d").style.display = "block";
renderHouseStorage3d(msg.storage || []);
} else if (msg.forRent) {
document.getElementById("houseForRentSection3d").style.display = "block";
document.getElementById("houseRentPrice3d").textContent = msg.rentPrice + "$";
} else {
document.getElementById("houseLockedSection3d").style.display = "block";
}
document.getElementById("housePanel3d").style.display = "block";
}
if (msg.type === "leitstelle_units") {
renderLeitstelleUnits3d(msg.units);
}
if (msg.type === "ticket_update") {
if (document.getElementById("ticketPanel3d").style.display === "block") loadMyTickets3d();
}
if (msg.type === "trunk_open") {
closeAllPanels3d();
currentTrunkCarId3d = msg.carId;
myInventory3d = msg.inventory || myInventory3d;
renderTrunkWindow3d(msg.trunk || []);
document.getElementById("trunkPanel3d").style.display = "block";
}
if (msg.type === "trunk_error") {
addChatMessage3d(msg.msg, "system");
}
if (msg.type === "trailer_shop_open") {
closeAllPanels3d();
renderTrailerShop3d(msg.catalog || []);
document.getElementById("trailerShopPanel3d").style.display = "block";
}
if (msg.type === "tune_menu_data") {
closeAllPanels3d();
renderTuneMenu3d(msg);
document.getElementById("tunePanel3d").style.display = "block";
}
if (msg.type === "wardrobe_open") {
closeAllPanels3d();
currentWardrobeItems3d = msg.wardrobe || [];
renderWardrobe3d();
document.getElementById("wardrobePanel3d").style.display = "block";
}
if (msg.type === "garage_open") {
closeAllPanels3d();
const list = document.getElementById("garageCars3d");
list.innerHTML = msg.cars.map(c => `
<div class="row">
<span>${c.model} ${c.is_stored ? "(eingelagert)" : "(draußen)"}</span>
${c.is_stored
? `<button onclick="garageRetrieve3d(${c.id})">Rausholen</button>`
: `<button class="secondary" onclick="garageStore3d(${c.id})">Einlagern</button>`}
</div>
`).join("") || `<p style="color:#888; font-size:12px;">Keine Fahrzeuge.</p>`;
document.getElementById("garagePanel3d").style.display = "block";
}
// -------------------------------------------------------
// FAHRZEUGE
// -------------------------------------------------------
if (msg.type === "cars") {
updateCarsRendering(msg.cars);
const mine = msg.cars.find(c => c.driverId === myPlayerId);
const wasDriving = !!drivingCarId;
drivingCarId = mine ? mine.id : null;
if (mine) {
myX = mine.x / 32;
myY = mine.y / 32;
}
const myPassengerCar = msg.cars.find(c => c.passengerId === myPlayerId);
passengerCarId = myPassengerCar ? myPassengerCar.id : null;
if (myPassengerCar) {
myX = myPassengerCar.x / 32;
myY = myPassengerCar.y / 32;
}
const activeCar = mine || myPassengerCar;
myMesh.visible = !activeCar && !firstPersonView3d; // im Auto sitzend oder in Ich-Sicht nicht extra die Lauffigur zeigen
document.getElementById("carHud3d").style.display = activeCar ? "block" : "none";
if (activeCar) {
document.getElementById("carSpeed3d").textContent = activeCar.speedKmh + " km/h";
document.getElementById("carFuel3d").textContent = Math.round(activeCar.fuel) + "/" + activeCar.tankSize + "L";
document.getElementById("carHealth3d").textContent = activeCar.health + "%";
checkDamageFlash3d("carHealth", activeCar.health);
}
updateGroupMarkers3d(msg.cars);
}
if (msg.type === "shop_error") {
let shownInPanel = false;
["shopMsg3d", "atmMsg3d", "garageMsg3d"].forEach(id => {
const el = document.getElementById(id);
if (el && el.closest(".panel3d").style.display === "block") {
el.textContent = msg.msg;
shownInPanel = true;
}
});
// Fallback: kein passendes Fenster offen (z.B. beim Auto-Einsteigen
// ohne Schlüssel) - Meldung darf dann nicht lautlos verschwinden
if (!shownInPanel) addChatMessage3d(msg.msg, "system");
}
if (msg.type === "shop_info" || msg.type === "garage_info") {
const div = document.createElement("div");
div.style.color = "#6fbf73";
div.textContent = msg.msg;
const box = document.getElementById("chatMessages3d");
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
if (msg.type === "map_data" && msg.tiles) {
buildGroundFromTiles(msg.tiles);
buildPoiMarkers(msg);
buildMapObjects3d(msg.objects);
buildRoadblocks3d(msg.roadblocks);
}
if (msg.type === "roadblock_update") {
buildRoadblocks3d(msg.roadblocks);
}
if (msg.type === "emote_triggered") {
const mesh = msg.playerId === myPlayerId ? myMesh : otherMeshes.get(msg.playerId);
if (mesh) playEmote3d(mesh, msg.emoteType);
}
if (msg.type === "car_theft_minigame_start") {
startHotwireMinigame3d(msg.carId);
}
if (msg.type === "fishing_minigame_start") {
startFishingMinigame3d();
}
if (msg.type === "state") {
const me = msg.players.find(p => p.id === myPlayerId);
if (me) {
myWorld = me.world;
if (myWorld && myWorld !== lastLoadedObjectsWorld) {
lastLoadedObjectsWorld = myWorld;
loadEditorObjectsForWorld(myWorld);
// Welt-Name sichtbar machen, um ihn leicht mit der
// Auswahl im Editor-Dropdown vergleichen zu können
const worldEl = document.getElementById("pingText3d");
if (worldEl) worldEl.title = "Aktuelle Welt: " + myWorld;
console.log("[3D-Objekte] Lade Objekte für Welt:", JSON.stringify(myWorld));
}
updateHud(me);
checkDamageFlash3d("health", me.health);
myInventory3d = me.inventory || [];
updateInventoryPanel(myInventory3d);
applyClothingAppearance3d(myMesh, me);
updateWantedDisplay3d(me.wantedLevel);
// Eigene Position NUR vom Server übernehmen, wenn gerade keine
// Bewegungstaste gedrückt ist - sonst kämpft die lokale
// Vorhersage (jeden Frame) gegen die Server-Korrektur (nur alle
// ~100ms) und das sieht wie Ruckeln/Zittern aus
const isMoving = keys["w"] || keys["a"] || keys["s"] || keys["d"];
if (!isMoving) {
myX = me.x / 32;
myY = me.y / 32;
}
}
const seenIds = new Set();
msg.players.forEach(p => {
if (p.id === myPlayerId) return;
seenIds.add(p.id);
let mesh = otherMeshes.get(p.id);
if (!mesh) {
mesh = createPlayerMesh(0xc0392b);
scene.add(mesh);
otherMeshes.set(p.id, mesh);
// einmalig anlegen: kleiner grüner Stern über dem Kopf,
// nur sichtbar wenn diese Person ein Kontakt ist
const friendMarker = makeLabelSprite("💚");
friendMarker.position.y = 2.1;
friendMarker.visible = false;
mesh.add(friendMarker);
mesh.userData.friendMarker = friendMarker;
}
if (mesh.userData.friendMarker) {
mesh.userData.friendMarker.visible = phoneContacts3d.some(c => c.id === p.id);
}
applyClothingAppearance3d(mesh, p);
// Nicht hart auf die neue Position springen, sondern nur das
// ZIEL merken - die eigentliche Bewegung passiert weich in der
// Render-Loop (siehe interpolateOtherPlayers)
mesh.userData.targetX = p.x / 32;
mesh.userData.targetZ = p.y / 32;
mesh.visible = p.world === myWorld;
});
// Spieler entfernen, die nicht mehr da sind
for (const [id, mesh] of otherMeshes) {
if (!seenIds.has(id)) {
scene.remove(mesh);
otherMeshes.delete(id);
}
}
}
};
}
connect();
// -------------------------------------------------------------
// STEUERUNG: WASD sendet dieselben "move"-Nachrichten wie das
// echte Spiel, damit die Server-Bewegungslogik unverändert greift
// -------------------------------------------------------------
const keys = {};
document.addEventListener("keydown", e => { if (e.key) keys[e.key.toLowerCase()] = true; });
document.addEventListener("keyup", e => { if (e.key) keys[e.key.toLowerCase()] = false; });
function handleInteractKey3d() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (drivingCarId) {
ws.send(JSON.stringify({ type: "car_exit_or_park" }));
} else if (passengerCarId) {
ws.send(JSON.stringify({ type: "car_exit_passenger" }));
} else if (tryTeleportThroughPortal3d()) {
// Portal/Fahrstuhl in der Nähe und verlinkt - Teleport hat Vorrang
// vor normaler Interaktion, kein weiterer Nachrichtenversand nötig
} else {
// Erst versuchen einzusteigen, sonst normale Interaktion (Shop/ATM/Garage usw.)
ws.send(JSON.stringify({ type: "car_enter" }));
ws.send(JSON.stringify({ type: "interact", x: myX * 32, y: myY * 32, world: myWorld }));
}
}
// Rein clientseitiger Teleport (Server kennt keine Portale/Fahrstühle -
// beide sind reine 3D-Deko-Objekte). Springt zur verlinkten Position und
// synchronisiert die neue Position anschließend per normaler move-Nachricht
function tryTeleportThroughPortal3d() {
const TELEPORT_RANGE = 1.3;
const nearby = portalLinks3d.find(p =>
p.linkedTo && Math.hypot(p.x - myX, p.z - myY) < TELEPORT_RANGE
);
if (!nearby) return false;
const target = portalLinks3d.find(p => p.type === nearby.type && p.portalId === nearby.linkedTo);
if (!target) return false;
myX = target.x;
myY = target.z;
myMesh.position.set(myX, 0, myY);
ws.send(JSON.stringify({ type: "portal_teleport", x: Math.round(myX * 32), y: Math.round(myY * 32) }));
addChatMessage3d(nearby.type === "elevator" ? "🛗 ...Fahrstuhl..." : "🌀 ...teleportiert...", "system");
return true;
}
function handleJobMenuKey3d() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
closeAllPanels3d();
ws.send(JSON.stringify({ type: "job_menu_open" }));
document.getElementById("jobMenuPanel3d").style.display = "block";
}
function handleInventoryKey3d() {
const panel = document.getElementById("inventoryPanel");
const isOpen = panel.style.display === "block";
closeAllPanels3d();
panel.style.display = isOpen ? "none" : "block";
}
document.addEventListener("keydown", e => {
if (!e.key) return; // manche synthetischen Events (Autofill, Erweiterungen) haben kein e.key
const typing = document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
if (typing) return;
if (e.key.toLowerCase() === "f") {
togglePhotoMode3d();
return;
}
// Im Zuschauer-Modus keine Spiel-Interaktionen auslösen (E/J/O/P/I/T
// usw.) - nur Bewegung (WASD/Q/E werden separat in
// updateSpectatorMovement3d gelesen) und der Chat zum Verlassen
if (spectatorMode3d) return;
// Im Foto-Modus ebenfalls keine Spiel-Interaktionen - nur Bewegung
// der freien Kamera (WASD/Q/E in updatePhotoModeMovement3d) und F
// selbst zum Beenden (oben schon behandelt)
if (photoMode3d) return;
if (e.key.toLowerCase() === "e") {
handleInteractKey3d();
}
if (e.key === "Escape") closeAllPanels3d();
if (e.key.toLowerCase() === "j") {
handleJobMenuKey3d();
}
if (e.key.toLowerCase() === "o") {
closeAllPanels3d();
document.getElementById("tabletPanel3d").style.display = "block";
}
if (e.key.toLowerCase() === "p") {
closeAllPanels3d();
document.getElementById("phonePanel3d").style.display = "block";
ws.send(JSON.stringify({ type: "phone_get_contacts" }));
}
if (e.key.toLowerCase() === "i") {
handleInventoryKey3d();
}
if (e.key.toLowerCase() === "t") {
ws.send(JSON.stringify({ type: "trunk_open" }));
}
if (drivingCarId && e.key.toLowerCase() === "q") {
ws.send(JSON.stringify({ type: "car_use_station" }));
}
if (drivingCarId && e.key.toLowerCase() === "l") {
ws.send(JSON.stringify({ type: "car_toggle", what: "emergency" }));
}
if (e.key.toLowerCase() === "v") {
toggleFirstPersonView3d();
}
});
function closeAllPanels3d() {
const leitstelleOpen = document.getElementById("leitstellePanel3d").style.display === "block";
document.querySelectorAll(".panel3d").forEach(p => p.style.display = "none");
if (leitstelleOpen && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "leitstelle_close" }));
}
}
function closePanel3d(id) {
document.getElementById(id).style.display = "none";
if (id === "leitstellePanel3d" && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "leitstelle_close" }));
}
}
let currentShopIsOwner3d = false;
function buyItem3d(itemId) {
ws.send(JSON.stringify({ type: "shop_buy", itemId }));
}
function atmDeposit3d() {
const amount = Number(document.getElementById("atmAmount3d").value);
ws.send(JSON.stringify({ type: "bank_deposit", amount }));
}
function atmWithdraw3d() {
const amount = Number(document.getElementById("atmAmount3d").value);
ws.send(JSON.stringify({ type: "bank_withdraw", amount }));
}
function garageStore3d(carId) {
ws.send(JSON.stringify({ type: "garage_store", carId }));
}
function garageRetrieve3d(carId) {
ws.send(JSON.stringify({ type: "garage_retrieve", carId }));
}
function escapeHtmlAdmin(str) {
const div = document.createElement("div");
div.textContent = str == null ? "" : String(str);
return div.innerHTML;
}
function addChatMessage3d(text, cls) {
const div = document.createElement("div");
div.style.color = cls === "system" ? "#e0a83a" : "#ddd";
div.textContent = text;
const box = document.getElementById("chatMessages3d");
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
// Fast 1:1 aus dem 2D-Client übernommen (renderJobMenu), nur die
// Container-IDs angepasst - Server schickt exakt dieselbe Struktur
function renderJobMenu3d(msg) {
const body = document.getElementById("jobMenuBody3d");
const title = document.getElementById("jobMenuTitle3d");
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) }));
}
closePanel3d("jobMenuPanel3d");
};
});
}
// -------------------------------------------------------------
// TABLET
// -------------------------------------------------------------
function showTabletTab3d(tab) {
document.querySelectorAll(".tab-btns3d button[data-tablet-tab]").forEach(b => b.classList.toggle("active", b.dataset.tabletTab === tab));
document.querySelectorAll("#tabletPanel3d .tab-panel3d").forEach(p => p.classList.remove("active"));
document.getElementById("tabletTab-" + tab).classList.add("active");
if (tab === "fms") renderFmsStatusButtons3d();
}
function tabletSearchPerson3d() {
const username = document.getElementById("tabletSearchInput3d").value.trim();
if (!username) return;
ws.send(JSON.stringify({ type: "tablet_search_person", username }));
}
function renderTabletSearchResult3d(data) {
const el = document.getElementById("tabletSearchResult3d");
if (!data.found) {
el.innerHTML = `<div class="result-card3d">Kein Spieler namens "${escapeHtmlAdmin(data.username)}" gefunden.</div>`;
return;
}
el.innerHTML = `
<div class="result-card3d">
<b>${escapeHtmlAdmin(data.username)}</b> (ID: ${data.playerId})<br>
Status: ${data.online ? `🟢 Online (${escapeHtmlAdmin(data.world || "?")})` : "⚫ Offline"}<br>
Fahndung: ${data.wantedLevel > 0 ? `⭐ Level ${data.wantedLevel}` : "Nicht gesucht"}<br>
Kopfgeld: ${data.bounty > 0 ? `💀 ${data.bounty}$` : "-"}<br>
Leben: ${data.health}%<br>
${data.jailed ? "🔒 In Haft<br>" : ""}${data.banned ? "🚫 Gebannt<br>" : ""}
</div>`;
}
function tabletLoadWantedList3d() {
ws.send(JSON.stringify({ type: "tablet_wanted_list" }));
}
function renderTabletWantedList3d(wanted) {
const el = document.getElementById("tabletWantedList3d");
if (!wanted || wanted.length === 0) {
el.innerHTML = `<p style="color:#888; font-size:12px;">Aktuell ist niemand gesucht.</p>`;
return;
}
el.innerHTML = wanted.map(w => `
<div class="wanted-row3d">
<span>${escapeHtmlAdmin(w.username)} ${"⭐".repeat(w.wantedLevel)} (${escapeHtmlAdmin(w.world)})</span>
<span>${w.bounty > 0 ? `💀 ${w.bounty}$` : ""}</span>
</div>`).join("");
}
function tabletLookupPlate3d() {
const plate = document.getElementById("tabletPlateInput3d").value.trim();
if (!plate) return;
ws.send(JSON.stringify({ type: "tablet_plate_lookup", plate }));
}
function renderTabletPlateResult3d(data) {
const el = document.getElementById("tabletPlateResult3d");
if (!data.found) {
el.innerHTML = `<div class="result-card3d">Kein Fahrzeug mit Kennzeichen "${escapeHtmlAdmin(data.plate)}" gefunden.</div>`;
return;
}
el.innerHTML = `
<div class="result-card3d">
Kennzeichen: <b>${escapeHtmlAdmin(data.plate)}</b><br>
Fahrzeug: ${escapeHtmlAdmin(data.model)}${data.isTrailer ? " (Anhänger)" : ""}<br>
Halter: ${escapeHtmlAdmin(data.ownerName)}<br>
Fahndung des Halters: ${data.ownerWanted > 0 ? `⭐ Level ${data.ownerWanted}` : "Nicht gesucht"}<br>
Kopfgeld auf Halter: ${data.ownerBounty > 0 ? `💀 ${data.ownerBounty}$` : "-"}
</div>`;
}
// -------------------------------------------------------------
// HANDY (Kontakte + SMS - Anrufe/LiveKit hier bewusst nicht
// eingebaut, das braucht eine eigene größere Runde)
// -------------------------------------------------------------
let phoneContacts3d = [];
let phonePending3d = [];
let currentSmsContact3d = null;
function showPhoneTab3d(tab) {
document.querySelectorAll(".tab-btns3d button[data-phone-tab]").forEach(b => b.classList.toggle("active", b.dataset.phoneTab === tab));
document.querySelectorAll("#phonePanel3d .tab-panel3d").forEach(p => p.classList.remove("active"));
document.getElementById("phoneTab-" + tab).classList.add("active");
}
function phoneAddContact3d() {
const username = document.getElementById("phoneAddInput3d").value.trim();
if (!username) return;
ws.send(JSON.stringify({ type: "phone_add_contact", username }));
document.getElementById("phoneAddInput3d").value = "";
}
function phoneAcceptContact3d(username) {
ws.send(JSON.stringify({ type: "phone_accept_contact", username }));
}
function renderPhoneContacts3d() {
document.getElementById("pendingRequests3d").innerHTML = phonePending3d.map(u => `
<div class="contact-row3d">
<span>📩 ${escapeHtmlAdmin(u)} möchte dich hinzufügen</span>
<button onclick="phoneAcceptContact3d('${escapeHtmlAdmin(u)}')">Annehmen</button>
</div>`).join("");
document.getElementById("contactsList3d").innerHTML = phoneContacts3d.map(c => `
<div class="contact-row3d">
<span>${c.online ? "🟢" : "⚫"} ${escapeHtmlAdmin(c.username)}</span>
<button class="call3d" ${c.online ? "" : "disabled"} onclick="initiateCall3d('${escapeHtmlAdmin(c.username)}')">📞 Anrufen</button>
</div>`).join("")
|| `<p style="color:#888; font-size:12px;">Noch keine Kontakte.</p>`;
const select = document.getElementById("smsContactSelect3d");
select.innerHTML = `<option value="">- Kontakt wählen -</option>` +
phoneContacts3d.map(c => `<option value="${escapeHtmlAdmin(c.username)}">${escapeHtmlAdmin(c.username)}</option>`).join("");
}
function openSmsThread3d() {
currentSmsContact3d = document.getElementById("smsContactSelect3d").value || null;
document.getElementById("smsMessages3d").innerHTML = "";
if (currentSmsContact3d) {
ws.send(JSON.stringify({ type: "phone_get_messages", username: currentSmsContact3d }));
}
}
function renderSmsThread3d(messages) {
document.getElementById("smsMessages3d").innerHTML = messages.map(m => `
<div style="margin-bottom:4px; color:${m.fromMe ? "#5b8def" : "#ddd"};">
<b>${m.fromMe ? "Du" : escapeHtmlAdmin(currentSmsContact3d)}:</b> ${escapeHtmlAdmin(m.text)}
</div>`).join("");
}
function sendSms3d() {
const text = document.getElementById("smsInput3d").value.trim();
if (!text || !currentSmsContact3d) return;
ws.send(JSON.stringify({ type: "phone_send_sms", username: currentSmsContact3d, text }));
document.getElementById("smsInput3d").value = "";
}
// -------------------------------------------------------------
// KLEIDUNGSLADEN
// -------------------------------------------------------------
function showClothingTab3d(slot) {
document.querySelectorAll(".tab-btns3d button[data-clothing-tab]").forEach(b => b.classList.toggle("active", b.dataset.clothingTab === slot));
document.querySelectorAll("#clothingPanel3d .tab-panel3d").forEach(p => p.classList.remove("active"));
document.getElementById("clothingTab-" + slot).classList.add("active");
}
function renderClothingCatalog3d(slot, items) {
const el = document.getElementById("clothingTab-" + slot);
if (!items || items.length === 0) {
el.innerHTML = `<p style="color:#888; font-size:12px;">Noch nichts im Angebot.</p>`;
return;
}
el.innerHTML = items.map(item => `
<div class="clothing-row3d">
<span class="swatch3d" style="background:${item.color};"></span>
<span>${escapeHtmlAdmin(item.name)}</span>
<button onclick="buyClothingItem3d(${item.id})">${item.price}$</button>
</div>`).join("");
}
function buyClothingItem3d(itemId) {
ws.send(JSON.stringify({ type: "buy_clothing_item", itemId }));
}
// -------------------------------------------------------------
// HAUS
// -------------------------------------------------------------
function buyHouse3d() {
ws.send(JSON.stringify({ type: "house_buy", houseId: currentHouseId3d }));
}
function rentHouse3d() {
ws.send(JSON.stringify({ type: "house_rent", houseId: currentHouseId3d }));
}
function renderHouseStorage3d(storage) {
document.getElementById("houseInvList3d").innerHTML = myInventory3d.map(item => `
<div class="inv-row"><span>${item.name || item.id} x${item.amount}</span>
<button class="jmBtn" onclick="houseStore3d('${item.id}')">→ Haus</button></div>`).join("")
|| `<p style="color:#888; font-size:12px;">Leer.</p>`;
document.getElementById("houseStorageList3d").innerHTML = storage.map(item => `
<div class="inv-row"><span>${item.name || item.id} x${item.amount}</span>
<button class="jmBtn" onclick="houseTake3d('${item.id}')">→ Inventar</button></div>`).join("")
|| `<p style="color:#888; font-size:12px;">Leer.</p>`;
}
function houseStore3d(itemId) {
ws.send(JSON.stringify({ type: "house_store", itemId, amount: 1 }));
}
function houseTake3d(itemId) {
ws.send(JSON.stringify({ type: "house_take", itemId, amount: 1 }));
}
// -------------------------------------------------------------
// SUPPORT-TICKETS (HTTP statt WebSocket, wie im 2D-Client)
// -------------------------------------------------------------
let currentTicketId3d = null;
async function ticketFetch3d(url, options = {}) {
options.headers = { ...(options.headers || {}), "Authorization": "Bearer " + token };
const res = await fetch(url, options);
return res.json();
}
function showTicketView3d(view) {
["ticketListView3d", "ticketNewView3d", "ticketDetailView3d"].forEach(id => {
document.getElementById(id).style.display = id === view ? "block" : "none";
});
}
function showTicketListView3d() {
showTicketView3d("ticketListView3d");
loadMyTickets3d();
}
async function loadMyTickets3d() {
showTicketView3d("ticketListView3d");
const data = await ticketFetch3d("/api/tickets");
if (!data.ok) return;
const statusLabels = { open: "Offen", in_progress: "In Bearbeitung", closed: "Geschlossen" };
document.getElementById("ticketListBody3d").innerHTML = (data.tickets || []).map(t => `
<div class="ticket-row3d" onclick="openTicketDetail3d(${t.id})">
<div class="top">
<span>${escapeHtmlAdmin(t.subject)}</span>
<span class="ticket-status3d ${t.status}">${statusLabels[t.status] || t.status}</span>
</div>
<div style="color:#888; font-size:11px;">#${t.id} · ${escapeHtmlAdmin(t.category)}</div>
</div>`).join("") || `<p style="color:#888; font-size:12px;">Noch keine Tickets erstellt.</p>`;
}
function openNewTicketPanel3d() {
document.getElementById("ticketNewSubject3d").value = "";
document.getElementById("ticketNewMessage3d").value = "";
showTicketView3d("ticketNewView3d");
}
async function submitNewTicket3d() {
const category = document.getElementById("ticketNewCategory3d").value;
const subject = document.getElementById("ticketNewSubject3d").value.trim();
const message = document.getElementById("ticketNewMessage3d").value.trim();
if (!subject || !message) return;
const data = await ticketFetch3d("/api/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject, category, message })
});
if (data.ok) openTicketDetail3d(data.id);
}
async function openTicketDetail3d(ticketId) {
currentTicketId3d = ticketId;
showTicketView3d("ticketDetailView3d");
const data = await ticketFetch3d("/api/tickets/" + ticketId);
if (!data.ok) { showTicketListView3d(); return; }
const statusLabels = { open: "Offen", in_progress: "In Bearbeitung", closed: "Geschlossen" };
document.getElementById("ticketDetailHeader3d").innerHTML =
`<b>#${data.ticket.id} ${escapeHtmlAdmin(data.ticket.subject)}</b><br>Status: ${statusLabels[data.ticket.status] || data.ticket.status}`;
const myUsername = localStorage.getItem("username");
const box = document.getElementById("ticketMessages3d");
box.innerHTML = data.messages.map(m => `
<div class="ticket-msg3d ${m.username === myUsername ? "mine" : ""}">
<div style="color:#888; font-size:10px;">${escapeHtmlAdmin(m.username)}${m.is_admin_reply ? " (Team)" : ""}</div>
${escapeHtmlAdmin(m.message)}
</div>`).join("");
box.scrollTop = box.scrollHeight;
}
async function submitTicketReply3d() {
if (!currentTicketId3d) return;
const text = document.getElementById("ticketReplyText3d").value.trim();
if (!text) return;
const data = await ticketFetch3d(`/api/tickets/${currentTicketId3d}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text })
});
if (data.ok) {
document.getElementById("ticketReplyText3d").value = "";
openTicketDetail3d(currentTicketId3d);
}
}
// -------------------------------------------------------------
// FMS-STATUS
// -------------------------------------------------------------
const FMS_STATUS_LABELS_3D = {
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 myFmsStatus3d = 2;
function renderFmsStatusButtons3d() {
document.getElementById("fmsStatusButtons3d").innerHTML = Object.entries(FMS_STATUS_LABELS_3D).map(([code, label]) => `
<div class="fms-btn3d ${Number(code) === myFmsStatus3d ? "active3d" : ""}" onclick="setFmsStatus3d(${code})">
<span><b>${code}</b> - ${escapeHtmlAdmin(label)}</span>
</div>`).join("");
}
function setFmsStatus3d(status) {
myFmsStatus3d = status;
ws.send(JSON.stringify({ type: "set_fms_status", status }));
renderFmsStatusButtons3d();
}
// -------------------------------------------------------------
// LEITSTELLE
// -------------------------------------------------------------
const LEITSTELLE_STATUS_COLORS_3D = { 0: "#c0392b", 1: "#27ae60", 2: "#27ae60", 3: "#e0a83a", 4: "#3498db", 5: "#9b59b6", 6: "#555", 7: "#e0a83a", 8: "#9b59b6" };
const JOB_TYPE_ICONS_3D = { police: "🚓", medic: "⛑️", fire: "🚒" };
function renderLeitstelleUnits3d(units) {
const el = document.getElementById("leitstelleUnits3d");
if (!units || units.length === 0) {
el.innerHTML = `<p style="color:#888; font-size:12px;">Aktuell keine Einheiten im Dienst.</p>`;
return;
}
el.innerHTML = units.map(u => `
<div class="ls-unit-row3d">
<span>${JOB_TYPE_ICONS_3D[u.jobType] || ""} <b>${escapeHtmlAdmin(u.username)}</b> · ${escapeHtmlAdmin(u.world)}</span>
<span style="background:${LEITSTELLE_STATUS_COLORS_3D[u.fmsStatus] || "#555"}; padding:2px 8px; border-radius:10px; font-size:11px;">
${u.fmsStatus} · ${escapeHtmlAdmin(FMS_STATUS_LABELS_3D[u.fmsStatus] || "?")}
</span>
</div>`).join("");
}
function sendLeitstelleDispatch3d() {
const jobType = document.getElementById("leitstelleTargetJob3d").value;
const text = document.getElementById("leitstelleDispatchText3d").value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: "leitstelle_dispatch", jobType, text }));
document.getElementById("leitstelleDispatchText3d").value = "";
}
// -------------------------------------------------------------
// BÖRSE
// -------------------------------------------------------------
let currentStockTab3d = "list";
function showStockTab3d(tab) {
currentStockTab3d = tab;
document.querySelectorAll("#stockPanel3d .tab-btns3d button").forEach(b => b.classList.toggle("active", b.dataset.stockTab === tab));
document.querySelectorAll("#stockPanel3d .tab-panel3d").forEach(p => p.classList.remove("active"));
document.getElementById("stockTab-" + tab).classList.add("active");
if (tab === "list") loadStockList3d(); else loadStockPortfolio3d();
}
async function loadStockList3d() {
const res = await fetch("/api/stocks");
const data = await res.json();
if (!data.ok) return;
document.getElementById("stockTab-list").innerHTML = (data.stocks || []).map(s => `
<div class="stock-row3d">
<div><span class="stock-symbol3d">${escapeHtmlAdmin(s.symbol)}</span><br><span class="stock-name3d">${escapeHtmlAdmin(s.name)}</span></div>
<span>${Number(s.price).toFixed(2)}$</span>
<div><input type="number" id="stockBuyAmount3d_${s.id}" value="1" min="1"><button onclick="tradeStock3d(${s.id}, 'buy')">Kaufen</button></div>
</div>`).join("");
}
async function loadStockPortfolio3d() {
const data = await ticketFetch3d("/api/stocks/portfolio");
const panel = document.getElementById("stockTab-portfolio");
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-row3d">
<div><span class="stock-symbol3d">${escapeHtmlAdmin(p.symbol)}</span><br><span class="stock-name3d">${p.shares}x · Wert: ${value}$</span></div>
<span>${Number(p.price).toFixed(2)}$</span>
<div><input type="number" id="stockSellAmount3d_${p.stock_id}" value="1" min="1" max="${p.shares}"><button class="sell3d" onclick="tradeStock3d(${p.stock_id}, 'sell')">Verkaufen</button></div>
</div>`;
}).join("");
}
function tradeStock3d(stockId, action) {
const input = document.getElementById(action === "buy" ? `stockBuyAmount3d_${stockId}` : `stockSellAmount3d_${stockId}`);
const shares = Math.floor(Number(input.value));
if (isNaN(shares) || shares < 1) return;
ws.send(JSON.stringify({ type: action === "buy" ? "stock_buy" : "stock_sell", stockId, shares }));
setTimeout(() => showStockTab3d(currentStockTab3d), 400);
}
// -------------------------------------------------------------
// HILFE
// -------------------------------------------------------------
function showHelp3d() {
const lines = [
"=== STEUERUNG ===",
"WASD - Laufen (oder Fahren, wenn im Auto)",
"E - Interagieren / Einsteigen / Aussteigen",
"I - Inventar öffnen/schließen",
"J - Job-Menü öffnen",
"O - Einsatz-Tablet öffnen (Polizei/Rettungsdienst/Feuerwehr)",
"P - Handy öffnen (Kontakte anrufen jetzt möglich)",
"Maus ziehen - Umschauen",
"ESC - Alle Fenster schließen",
"",
"=== CHAT-BEFEHLE ===",
"/ticket - Support-Tickets öffnen",
"/leitstelle - Leitstellen-Übersicht (Polizei/Rettungsdienst/Feuerwehr)",
"/börse - Börse öffnen (Aktien kaufen/verkaufen)",
"/verkaufen - gestohlenes Auto beim Hehler verkaufen (musst dort stehen + im Auto sitzen)",
"/hotwire - fremdes Auto in der Nähe kurzschließen (Timing-Minispiel, LEERTASTE)",
"/angeln - in Wassernähe angeln (Timing-Minispiel, LEERTASTE, kleine Geldbelohnung)",
"E in der Nähe eines verlinkten Portals/Fahrstuhls teleportiert automatisch",
"/gang create Name Tag - Bande gründen",
"/gang invite Username - zur Bande einladen",
"/gang accept Bandenname - Einladung annehmen",
"/gang kick Username - aus Bande werfen",
"/gang leave - Bande verlassen",
"/gang deposit Betrag - in Bandenkasse einzahlen",
"/gang withdraw Betrag - aus Bandenkasse abheben",
"/gang info - Bandeninfo anzeigen",
"/gate claim - Territorium-Tor beanspruchen (in der Nähe stehend)",
"/wave, /dance, /point, /sit, /salute - Gesten (nur optisch, für alle in deiner Welt sichtbar)",
"/sperre - Straßensperre an deiner Position aufstellen (nur Polizei im Dienst)",
"/sperre entfernen - Straßensperre in der Nähe entfernen (nur Polizei im Dienst)",
"/admin duty - Admin-Dienst umschalten (nur Admins)",
"/spectate - Zuschauer-Modus umschalten, freies Fliegen ohne einzugreifen (nur Admins)",
"/setpoint Typ Name - neuen Standort an deiner Position anlegen (nur Admins, siehe /setpoint types)",
];
lines.forEach(line => addChatMessage3d(line, "system"));
}
// -------------------------------------------------------------
// STANDORT-ERSTELLUNG DIREKT IM SPIEL (statt über admin_points.html
// oder den 3D-Editor) - nutzt dieselben Admin-Endpunkte, die auch die
// Admin-Weboberfläche verwendet, mit der eigenen aktuellen Position
// -------------------------------------------------------------
const SETPOINT_TYPES = {
garage: { endpoint: "/api/admin/garages", label: "Garage" },
shop: { endpoint: "/api/admin/shops", label: "Shop" },
gasstation: { endpoint: "/api/admin/gas_stations", label: "Tankstelle" },
repairshop: { endpoint: "/api/admin/repair_shops", label: "Werkstatt" },
taxi: { endpoint: "/api/admin/taxi_stands", label: "Taxistand" },
hospital: { endpoint: "/api/admin/hospitals", label: "Krankenhaus" },
prison: { endpoint: "/api/admin/prisons", label: "Gefängnis" },
firestation: { endpoint: "/api/admin/fire_stations", label: "Feuerwache" },
impoundlot: { endpoint: "/api/admin/impound_lots", label: "Abschlepphof" },
jobcenter: { endpoint: "/api/admin/jobcenters", label: "Jobcenter" },
insurance: { endpoint: "/api/admin/insurance_offices", label: "Versicherungsbüro" },
plateoffice: { endpoint: "/api/admin/plate_offices", label: "Zulassungsstelle" },
clothing: { endpoint: "/api/admin/clothing_shops", label: "Kleidungsladen" },
trailer: { endpoint: "/api/admin/trailer_shops", label: "Anhänger-Shop" },
blackmarket: { endpoint: "/api/admin/black_market_spots", label: "Hehler" }
};
async function handleSetpointCommand3d(raw) {
if (!myIsAdmin) {
addChatMessage3d("Kein Admin-Zugriff.", "system");
return;
}
const parts = raw.split(" ");
const typeArg = (parts[1] || "").toLowerCase();
if (typeArg === "types" || !typeArg) {
addChatMessage3d("Verfügbare Typen: " + Object.keys(SETPOINT_TYPES).join(", "), "system");
addChatMessage3d("Nutzung: /setpoint Typ Name (z.B. /setpoint garage Stadtgarage Nord)", "system");
return;
}
const def = SETPOINT_TYPES[typeArg];
if (!def) {
addChatMessage3d(`Unbekannter Typ "${typeArg}". Siehe /setpoint types`, "system");
return;
}
const name = parts.slice(2).join(" ").trim();
if (!name) {
addChatMessage3d("Bitte einen Namen angeben: /setpoint " + typeArg + " Name", "system");
return;
}
if (!myWorld) {
addChatMessage3d("Aktuelle Welt noch nicht bekannt - kurz warten und nochmal versuchen.", "system");
return;
}
const x = Math.round(myX);
const y = Math.round(myY);
try {
const res = await fetch(def.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token },
body: JSON.stringify({ name, world: myWorld, x, y })
});
const data = await res.json();
if (data.ok) {
addChatMessage3d(`✅ ${def.label} "${name}" angelegt bei (${x}, ${y}) in ${myWorld}. Zum Anzeigen die Welt neu betreten.`, "system");
} else {
addChatMessage3d("❌ Fehler: " + (data.error || "unbekannt"), "system");
}
} catch {
addChatMessage3d("❌ Anfrage fehlgeschlagen.", "system");
}
}
// -------------------------------------------------------------
// KOFFERRAUM
// -------------------------------------------------------------
let currentTrunkCarId3d = null;
function renderTrunkWindow3d(trunk) {
document.getElementById("trunkInvList3d").innerHTML = myInventory3d.map(item => {
const info = itemInfoCache3d[item.id];
const label = info ? info.name : item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="trunkStore3d('${item.id}')">→</button></div>`;
}).join("") || `<p style="color:#888; font-size:12px;">Leer.</p>`;
document.getElementById("trunkContentList3d").innerHTML = trunk.map(item => {
const info = itemInfoCache3d[item.id];
const label = info ? info.name : item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="trunkTake3d('${item.id}')">→</button></div>`;
}).join("") || `<p style="color:#888; font-size:12px;">Leer.</p>`;
}
function trunkStore3d(itemId) {
ws.send(JSON.stringify({ type: "trunk_store", itemId, amount: 1 }));
}
function trunkTake3d(itemId) {
ws.send(JSON.stringify({ type: "trunk_take", itemId, amount: 1 }));
}
// -------------------------------------------------------------
// ANHÄNGER-SHOP
// -------------------------------------------------------------
function renderTrailerShop3d(catalog) {
const el = document.getElementById("trailerShopCatalog3d");
if (catalog.length === 0) {
el.innerHTML = `<p style="color:#888; font-size:12px;">Aktuell ist kein Anhänger-Modell im Angebot.</p>`;
return;
}
el.innerHTML = catalog.map(item => `
<div class="clothing-row3d">
<span>${escapeHtmlAdmin(item.model)}</span>
<button onclick="buyTrailer3d('${escapeHtmlAdmin(item.model)}')">${item.price}$</button>
</div>`).join("");
}
function buyTrailer3d(model) {
ws.send(JSON.stringify({ type: "trailer_shop_buy", model }));
}
// -------------------------------------------------------------
// TUNING
// -------------------------------------------------------------
const TUNE_STAT_LABELS_3D = { speed: "Höchstgeschwindigkeit", accel: "Beschleunigung", brake: "Bremsen" };
function renderTuneMenu3d(msg) {
const body = document.getElementById("tuneBody3d");
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_3D[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="tunePaintColor3d" value="${msg.currentColor || "#c0392b"}" style="width:50px; height:30px; padding:0; border:none; cursor:pointer;">
<button class="jmBtn" id="tunePaintBtn3d">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 }));
closePanel3d("tunePanel3d");
};
});
document.getElementById("tunePaintBtn3d").onclick = () => {
const color = document.getElementById("tunePaintColor3d").value;
ws.send(JSON.stringify({ type: "tune_paint", color }));
closePanel3d("tunePanel3d");
};
}
// -------------------------------------------------------------
// KLEIDERSCHRANK IM HAUS (getrennt von der normalen Lagerkiste)
// -------------------------------------------------------------
let currentWardrobeItems3d = [];
function renderWardrobe3d() {
document.getElementById("wardrobeInvList3d").innerHTML = myInventory3d.map(item => {
const info = itemInfoCache3d[item.id];
const label = info ? info.name : item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="wardrobeStore3d('${item.id}')">→</button></div>`;
}).join("") || `<p style="color:#888; font-size:12px;">Leer.</p>`;
document.getElementById("wardrobeContentList3d").innerHTML = currentWardrobeItems3d.map(item => {
const label = findClothingCatalogItem3d(Number(String(item.id).replace("clothing_", "")))?.name || item.id;
return `<div class="inv-row"><span>${label} x${item.amount}</span><button class="jmBtn" onclick="wardrobeTake3d('${item.id}')">→</button></div>`;
}).join("") || `<p style="color:#888; font-size:12px;">Leer.</p>`;
}
function wardrobeStore3d(itemId) {
ws.send(JSON.stringify({ type: "wardrobe_store", itemId, amount: 1 }));
}
function wardrobeTake3d(itemId) {
ws.send(JSON.stringify({ type: "wardrobe_take", itemId, amount: 1 }));
}
// -------------------------------------------------------------
// BANK-PIN ÄNDERN
// -------------------------------------------------------------
function atmSetPin3d() {
const oldPin = document.getElementById("atmOldPin3d").value.trim();
const newPin = document.getElementById("atmNewPin3d").value.trim();
if (newPin.length !== 4) {
document.getElementById("atmPinMsg3d").textContent = "Neue PIN muss 4 Ziffern haben.";
document.getElementById("atmPinMsg3d").style.color = "#e06c6c";
return;
}
ws.send(JSON.stringify({ type: "bank_set_pin", oldPin, newPin }));
}
// -------------------------------------------------------------
// TAG/NACHT + WETTER (dieselbe Helligkeits-Formel wie im 2D-Client,
// hier auf Sonnenlicht-Intensität und Himmelsfarbe angewendet)
// -------------------------------------------------------------
let gameHour3d = 12;
let weather3d = "clear";
function getDayBrightness3d() {
// 1 = heller Tag (Mittag), 0 = tiefste Nacht (Mitternacht)
return (Math.cos(((gameHour3d - 12) / 24) * Math.PI * 2) + 1) / 2;
}
function applyDayNightAndWeather3d() {
const brightness = getDayBrightness3d();
sun.intensity = 0.25 + brightness * 0.75;
scene.background.setRGB(
0.53 * (0.15 + brightness * 0.85),
0.81 * (0.15 + brightness * 0.85),
0.92 * (0.15 + brightness * 0.85)
);
if (scene.fog) {
scene.fog.color.copy(scene.background);
// Nebel-Wetter: Sichtweite deutlich verkürzen, sonst normale Werte
// (bei niedriger Qualitätsstufe zusätzlich gedeckelt, damit die
// automatische Performance-Anpassung nicht bei jedem
// world_info-Update überschrieben wird)
const baseFar = lowQualityMode3d ? 130 : 220;
if (weather3d === "fog") {
scene.fog.near = 8;
scene.fog.far = 45;
} else {
scene.fog.near = 60;
scene.fog.far = baseFar;
}
}
document.getElementById("rainOverlay3d").style.display = weather3d === "rain" ? "block" : "none";
document.getElementById("snowOverlay3d").style.display = weather3d === "snow" ? "block" : "none";
// Laternen an, sobald es dämmert (brightness < 0.35, wie im 2D-Spiel
// für "isNight" verwendet)
const lampIntensity = brightness < 0.35 ? 1.2 : 0;
lampLights3d.forEach(light => { light.intensity = lampIntensity; });
// Fenster mit glow:true leuchten in derselben Nacht-Phase warm gelb
const windowGlowIntensity = brightness < 0.35 ? 0.9 : 0;
windowGlowMats3d.forEach(mat => { mat.emissiveIntensity = windowGlowIntensity; });
}
// -------------------------------------------------------------
// WELT-EVENTS
// -------------------------------------------------------------
let activeEvents3d = {};
function formatEventTimeLeft3d(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 renderEventBanner3d(events) {
activeEvents3d = events || {};
const el = document.getElementById("eventBanner3d");
const parts = [];
if (activeEvents3d.doubleXp && activeEvents3d.doubleXp.active) {
parts.push(`⭐ Doppel-XP (${formatEventTimeLeft3d(activeEvents3d.doubleXp.endsAt)})`);
}
if (activeEvents3d.discount && activeEvents3d.discount.active) {
parts.push(`💰 ${activeEvents3d.discount.percent}% Rabatt (${formatEventTimeLeft3d(activeEvents3d.discount.endsAt)})`);
}
if (parts.length === 0) {
el.style.display = "none";
} else {
el.style.display = "block";
el.textContent = parts.join(" • ");
}
}
setInterval(() => renderEventBanner3d(activeEvents3d), 30000);
// -------------------------------------------------------------
// ANRUFE
// -------------------------------------------------------------
let incomingCallId3d = null;
let callRoom3d = null;
let activeCallInfo3d = null;
function initiateCall3d(username) {
ws.send(JSON.stringify({ type: "phone_call_request", username }));
}
function showIncomingCall3d(callId, callerName) {
incomingCallId3d = callId;
document.getElementById("incomingCallText3d").textContent = `📞 Anruf von ${callerName}...`;
document.getElementById("incomingCallBox3d").style.display = "block";
}
function hideIncomingCall3d() {
incomingCallId3d = null;
document.getElementById("incomingCallBox3d").style.display = "none";
}
function acceptIncomingCall3d() {
if (incomingCallId3d === null) return;
ws.send(JSON.stringify({ type: "phone_call_accept", callId: incomingCallId3d }));
hideIncomingCall3d();
}
function declineIncomingCall3d() {
if (incomingCallId3d === null) return;
ws.send(JSON.stringify({ type: "phone_call_decline", callId: incomingCallId3d }));
hideIncomingCall3d();
}
async function connectToCallRoom3d(data) {
try {
const room = new LivekitClient.Room();
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
if (track.kind !== "audio") return;
const el = track.attach();
el.id = "callAudioEl3d";
el.autoplay = true;
document.body.appendChild(el);
});
room.on(LivekitClient.RoomEvent.Disconnected, () => {
teardownCallRoom3d(false);
});
await room.connect(data.url, data.token);
await room.localParticipant.setMicrophoneEnabled(true);
callRoom3d = room;
activeCallInfo3d = { otherUsername: data.otherUsername };
document.getElementById("outgoingCallBox3d").style.display = "none";
document.getElementById("activeCallText3d").textContent = `📞 Im Gespräch mit ${data.otherUsername}`;
document.getElementById("activeCallBar3d").style.display = "block";
} catch (err) {
console.error("[Anruf] Verbindung fehlgeschlagen:", err);
addChatMessage3d("Anruf-Verbindung fehlgeschlagen.", "system");
}
}
async function teardownCallRoom3d(notifyServer) {
if (callRoom3d) {
try { await callRoom3d.disconnect(); } catch {}
callRoom3d = null;
}
document.getElementById("callAudioEl3d")?.remove();
if (notifyServer && activeCallInfo3d) {
ws.send(JSON.stringify({ type: "phone_call_end", otherUsername: activeCallInfo3d.otherUsername }));
}
activeCallInfo3d = null;
document.getElementById("activeCallBar3d").style.display = "none";
document.getElementById("outgoingCallBox3d").style.display = "none";
}
function endCall3d() {
teardownCallRoom3d(true);
}
// -------------------------------------------------------------
// AUTO-DIEBSTAHL-MINISPIEL: Nadel pendelt hin und her, LEERTASTE
// drücken, wenn sie im grünen Bereich ist. Der Server entscheidet
// trotzdem final (Cooldown, Auto noch verfügbar?), das hier ist nur
// die Geschicklichkeits-Komponente
// -------------------------------------------------------------
let hotwireActive3d = false;
let hotwireCarId3d = null;
let hotwireNeedlePos3d = 0; // 0-100
let hotwireDirection3d = 1;
let hotwireZoneStart3d = 0;
let hotwireZoneWidth3d = 18;
let hotwireRafId3d = null;
let hotwireTimeoutId3d = null;
function startHotwireMinigame3d(carId) {
hotwireActive3d = true;
hotwireCarId3d = carId;
hotwireNeedlePos3d = 0;
hotwireDirection3d = 1;
hotwireZoneWidth3d = 15 + Math.random() * 10; // 15-25% breit
hotwireZoneStart3d = Math.random() * (100 - hotwireZoneWidth3d);
const zoneEl = document.getElementById("hotwireZone3d");
zoneEl.style.left = hotwireZoneStart3d + "%";
zoneEl.style.width = hotwireZoneWidth3d + "%";
document.getElementById("hotwireGame3d").style.display = "block";
document.addEventListener("keydown", hotwireKeyHandler3d);
const speed = 1.8; // % pro Frame, ungefähr
function tick() {
hotwireNeedlePos3d += speed * hotwireDirection3d;
if (hotwireNeedlePos3d >= 100) { hotwireNeedlePos3d = 100; hotwireDirection3d = -1; }
if (hotwireNeedlePos3d <= 0) { hotwireNeedlePos3d = 0; hotwireDirection3d = 1; }
document.getElementById("hotwireNeedle3d").style.left = hotwireNeedlePos3d + "%";
hotwireRafId3d = requestAnimationFrame(tick);
}
tick();
// Auto-Fehlschlag nach 5 Sekunden, falls keine Reaktion kommt
hotwireTimeoutId3d = setTimeout(() => finishHotwireMinigame3d(false), 5000);
}
function hotwireKeyHandler3d(e) {
if (e.code !== "Space" && e.key !== " ") return;
e.preventDefault();
const inZone = hotwireNeedlePos3d >= hotwireZoneStart3d && hotwireNeedlePos3d <= hotwireZoneStart3d + hotwireZoneWidth3d;
finishHotwireMinigame3d(inZone);
}
function finishHotwireMinigame3d(success) {
if (!hotwireActive3d) return;
hotwireActive3d = false;
cancelAnimationFrame(hotwireRafId3d);
clearTimeout(hotwireTimeoutId3d);
document.removeEventListener("keydown", hotwireKeyHandler3d);
document.getElementById("hotwireGame3d").style.display = "none";
ws.send(JSON.stringify({ type: "car_theft_minigame_result", carId: hotwireCarId3d, success }));
hotwireCarId3d = null;
}
// -------------------------------------------------------------
// ANGELN - gleicher Timing-Minispiel-Mechanismus wie beim
// Auto-Kurzschließen, eigene ID/Variablen-Namen
// -------------------------------------------------------------
let fishingActive3d = false;
let fishingNeedlePos3d = 0;
let fishingDirection3d = 1;
let fishingZoneStart3d = 0;
let fishingZoneWidth3d = 18;
let fishingRafId3d = null;
let fishingTimeoutId3d = null;
function startFishingMinigame3d() {
fishingActive3d = true;
fishingNeedlePos3d = 0;
fishingDirection3d = 1;
fishingZoneWidth3d = 12 + Math.random() * 8; // etwas schmaler als beim Auto - schwerer
fishingZoneStart3d = Math.random() * (100 - fishingZoneWidth3d);
const zoneEl = document.getElementById("fishingZone3d");
zoneEl.style.left = fishingZoneStart3d + "%";
zoneEl.style.width = fishingZoneWidth3d + "%";
document.getElementById("fishingGame3d").style.display = "block";
document.addEventListener("keydown", fishingKeyHandler3d);
const speed = 2.2; // etwas schneller als beim Auto - schwerer
function tick() {
fishingNeedlePos3d += speed * fishingDirection3d;
if (fishingNeedlePos3d >= 100) { fishingNeedlePos3d = 100; fishingDirection3d = -1; }
if (fishingNeedlePos3d <= 0) { fishingNeedlePos3d = 0; fishingDirection3d = 1; }
document.getElementById("fishingNeedle3d").style.left = fishingNeedlePos3d + "%";
fishingRafId3d = requestAnimationFrame(tick);
}
tick();
fishingTimeoutId3d = setTimeout(() => finishFishingMinigame3d(false), 5000);
}
function fishingKeyHandler3d(e) {
if (e.code !== "Space" && e.key !== " ") return;
e.preventDefault();
const inZone = fishingNeedlePos3d >= fishingZoneStart3d && fishingNeedlePos3d <= fishingZoneStart3d + fishingZoneWidth3d;
finishFishingMinigame3d(inZone);
}
function finishFishingMinigame3d(success) {
if (!fishingActive3d) return;
fishingActive3d = false;
cancelAnimationFrame(fishingRafId3d);
clearTimeout(fishingTimeoutId3d);
document.removeEventListener("keydown", fishingKeyHandler3d);
document.getElementById("fishingGame3d").style.display = "none";
ws.send(JSON.stringify({ type: "fishing_minigame_result", success }));
}
// -------------------------------------------------------------
// MINIMAP: einfache Draufsicht - eigene Figur immer zentriert (Norden
// oben, dreht sich nicht mit), andere Spieler + Kartenpunkte als
// Punkte relativ dazu. Wird nicht jeden Frame neu gezeichnet (unnötig
// für ein grobes Radar), ein Intervall reicht
// -------------------------------------------------------------
const minimapCtx3d = document.getElementById("minimapCanvas3d").getContext("2d");
const MINIMAP_RANGE = 40; // Einheiten Sichtweite auf der Minimap (Radius)
const MINIMAP_POI_COLORS_3D = {
shops: "#5b8def", atms: "#f0c419", garages: "#8e6a3d", hospitals: "#ff4d4d",
gasStations: "#e74c3c", jobcenters: "#27ae60", clothingShops: "#e84393",
blackMarketSpots: "#5a1a4a"
};
function drawMinimap3d() {
const ctx = minimapCtx3d;
ctx.clearRect(0, 0, 150, 150);
ctx.fillStyle = "#0d1a10";
ctx.fillRect(0, 0, 150, 150);
const scale = 75 / MINIMAP_RANGE; // Canvas-Radius 75px
// Kartenpunkte
for (const [key, color] of Object.entries(MINIMAP_POI_COLORS_3D)) {
const list = lastMapData3d[key];
if (!Array.isArray(list)) continue;
ctx.fillStyle = color;
list.forEach(poi => {
const dx = (poi.x / 32 - myX) * scale;
const dy = (poi.y / 32 - myY) * scale;
if (Math.hypot(dx, dy) > 75) return;
ctx.beginPath();
ctx.arc(75 + dx, 75 + dy, 3, 0, Math.PI * 2);
ctx.fill();
});
}
// Territorium-Zonen: eigener Block, da jede Zone ihre eigene
// Bande-Farbe hat (statt einer festen Typ-Farbe wie oben)
(lastMapData3d.territoryZones || []).forEach(zone => {
const dx = (zone.x / 32 - myX) * scale;
const dy = (zone.y / 32 - myY) * scale;
if (Math.hypot(dx, dy) > 75) return;
ctx.fillStyle = zone.ownerColor || "#9b59b6";
ctx.beginPath();
ctx.arc(75 + dx, 75 + dy, 4, 0, Math.PI * 2);
ctx.fill();
});
// andere Spieler - Freunde grün+größer hervorgehoben, alle anderen normal weiß
const friendIds3d = new Set(phoneContacts3d.map(c => c.id));
for (const [id, mesh] of otherMeshes) {
const dx = (mesh.position.x - myX) * scale;
const dz = (mesh.position.z - myY) * scale;
if (Math.hypot(dx, dz) > 75) continue;
const isFriend = friendIds3d.has(id);
ctx.fillStyle = isFriend ? "#4fd968" : "#eeeeee";
ctx.beginPath();
ctx.arc(75 + dx, 75 + dz, isFriend ? 4.5 : 3, 0, Math.PI * 2);
ctx.fill();
}
// eigene Figur, immer zentriert, als kleiner Pfeil in Laufrichtung
ctx.save();
ctx.translate(75, 75);
ctx.rotate(myMesh.rotation.y);
ctx.fillStyle = "#5b8def";
ctx.beginPath();
ctx.moveTo(0, -6);
ctx.lineTo(4, 5);
ctx.lineTo(-4, 5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
setInterval(drawMinimap3d, 300);
function updateWantedDisplay3d(level) {
const el = document.getElementById("minimapWanted3d");
if (!level || level <= 0) {
el.style.display = "none";
return;
}
el.style.display = "block";
el.textContent = "⭐".repeat(level);
}
// -------------------------------------------------------------
// EMOTES/GESTEN: nutzt dieselben Arm-/Bein-Drehpunkte wie die
// Lauf-Animation für eine kurze Pose, plus eine Emoji-Sprechblase
// über dem Kopf (einfaches THREE.Sprite mit Canvas-Textur)
// -------------------------------------------------------------
const EMOTE_EMOJIS_3D = { wave: "👋", dance: "💃", point: "👉", sit: "🪑", salute: "🫡" };
function makeEmojiSprite3d(emoji) {
const c = document.createElement("canvas");
c.width = c.height = 64;
const ctx2d = c.getContext("2d");
ctx2d.font = "48px sans-serif";
ctx2d.textAlign = "center";
ctx2d.textBaseline = "middle";
ctx2d.fillText(emoji, 32, 34);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.scale.set(0.6, 0.6, 0.6);
sprite.position.y = 2.0;
return sprite;
}
function playEmote3d(mesh, emoteType) {
const parts = mesh.userData.parts;
const emoji = EMOTE_EMOJIS_3D[emoteType];
if (emoji) {
const bubble = makeEmojiSprite3d(emoji);
mesh.add(bubble);
setTimeout(() => mesh.remove(bubble), 2000);
}
if (!parts || !parts.armPivotLeft) return;
mesh.userData.emoteActive = true;
if (emoteType === "dance") {
// kurze wackelnde Schleife statt einer einzelnen Pose
let ticks = 0;
const wiggle = setInterval(() => {
const s = Math.sin(ticks * 0.9);
parts.armPivotLeft.rotation.x = -1.2 + s * 0.4;
parts.armPivotRight.rotation.x = -1.2 - s * 0.4;
parts.legPivotLeft.rotation.x = s * 0.3;
parts.legPivotRight.rotation.x = -s * 0.3;
ticks++;
}, 90);
setTimeout(() => clearInterval(wiggle), 1800);
} else if (emoteType === "wave") {
parts.armPivotRight.rotation.x = -2.4;
parts.armPivotRight.rotation.z = -0.5;
} else if (emoteType === "point") {
parts.armPivotRight.rotation.x = -1.5;
} else if (emoteType === "salute") {
parts.armPivotRight.rotation.x = -2.0;
parts.armPivotRight.rotation.z = -1.0;
} else if (emoteType === "sit") {
parts.legPivotLeft.rotation.x = -1.3;
parts.legPivotRight.rotation.x = -1.3;
}
setTimeout(() => {
parts.armPivotLeft.rotation.set(0, 0, 0);
parts.armPivotRight.rotation.set(0, 0, 0);
parts.legPivotLeft.rotation.set(0, 0, 0);
parts.legPivotRight.rotation.set(0, 0, 0);
mesh.userData.emoteActive = false;
}, emoteType === "dance" ? 1800 : 1800);
}
// -------------------------------------------------------------
// SCHADEN-BILDSCHIRM-BLITZ: roter Rand blitzt kurz auf, wenn Leben
// oder das gefahrene Auto Schaden nehmen (Wert sinkt gegenüber dem
// letzten bekannten Stand) - zwei getrennte Quellen, damit ein
// Auto-Schaden nicht fälschlich als eigener Lebens-Schaden gilt
// -------------------------------------------------------------
const lastDamageValues3d = { health: null, carHealth: null };
let damageFlashTimeout3d = null;
// -------------------------------------------------------------
// FALLSCHADEN: verfolgt, ob die Figur gerade auf einer Brücke steht
// (Höhe > 0) - verlässt sie den Bereich, ohne über eine der schmalen
// Rampen an den Enden zu gehen, gilt das als Sturz. Rein clientseitig
// erkannt (der Server kennt keine Höhe), der eigentliche Schaden wird
// dann per Nachricht ans Backend gemeldet
// -------------------------------------------------------------
let currentElevation3d = 0;
let lastElevation3d = 0;
let lastFallReportAt3d = 0;
function updateElevationAndFallDamage3d() {
let onPlatform = 0;
for (const p of elevatedPlatforms3d) {
if (myX >= p.minX && myX <= p.maxX && myY >= p.minZ && myY <= p.maxZ) {
onPlatform = Math.max(onPlatform, p.height);
}
}
currentElevation3d = onPlatform;
// Absturz erkannt: war deutlich oben, ist jetzt (fast) am Boden
if (lastElevation3d > 1.0 && currentElevation3d < 0.3) {
const fallHeight = lastElevation3d;
const now = Date.now();
if (now - lastFallReportAt3d > 2000 && ws && ws.readyState === WebSocket.OPEN) {
lastFallReportAt3d = now;
ws.send(JSON.stringify({ type: "fall_damage", height: fallHeight }));
triggerDamageFlash3d();
}
}
lastElevation3d = currentElevation3d;
}
function checkDamageFlash3d(sourceKey, newValue) {
const prev = lastDamageValues3d[sourceKey];
lastDamageValues3d[sourceKey] = newValue;
if (prev !== null && newValue < prev - 0.5) triggerDamageFlash3d();
}
function triggerDamageFlash3d() {
const el = document.getElementById("damageFlash3d");
el.style.opacity = "1";
clearTimeout(damageFlashTimeout3d);
damageFlashTimeout3d = setTimeout(() => { el.style.opacity = "0"; }, 180);
}
const PLAYER_COLLISION_RADIUS = 0.3;
function isBlockedByEditorObjects(x, z) {
for (const c of editorColliders) {
const dist = Math.hypot(x - c.x, z - c.z);
if (dist < c.radius + PLAYER_COLLISION_RADIUS) return true;
}
return false;
}
function isBlockedAnywhere3d(x, z) {
return isBlockedByEditorObjects(x, z) || isBlockedByMapObjects3d(x, z) || isBlockedByRoadblocks3d(x, z);
}
let isSwimming3d = false;
function isInWater3d(x, z) {
for (const w of waterZones3d) {
if (x >= w.minX && x <= w.maxX && z >= w.minZ && z <= w.maxZ) return true;
}
return false;
}
function isNearWater3d(x, z, margin) {
for (const w of waterZones3d) {
if (x >= w.minX - margin && x <= w.maxX + margin && z >= w.minZ - margin && z <= w.maxZ + margin) return true;
}
return false;
}
function isAnyPanelOpen3d() {
return [...document.querySelectorAll(".panel3d")].some(p => p.style.display === "block");
}
function updateMovement() {
if (!ws || ws.readyState !== WebSocket.OPEN || myPlayerId === null) return;
if (spectatorMode3d) return; // eigene Figur bewegt sich nicht mit, nur die freie Kamera
if (photoMode3d) return; // eigene Figur bleibt stehen, nur die Kamera fliegt frei
// Mitfahrer: keine eigene Bewegung, Position kommt vom Server (ans Auto gekoppelt)
if (passengerCarId) return;
// Fahren: WASD steuert Gas/Bremse/Lenkung statt Laufen - bewusst VOR
// der Fenster-Prüfung, damit ein zufällig offen gebliebenes Fenster
// (z.B. weil E gleichzeitig car_enter+interact schickt) das Fahren
// nicht blockiert
if (drivingCarId) {
let throttle = 0, steer = 0;
if (keys["w"]) throttle = 1;
if (keys["s"]) throttle = -1;
if (keys["a"]) steer = -1;
if (keys["d"]) steer = 1;
ws.send(JSON.stringify({ type: "car_control", throttle, steer }));
return;
}
// Ab hier nur noch zu Fuß unterwegs - hier darf ein offenes Fenster
// (Ticket/Shop/Inventar/...) die Bewegung blockieren, Fahren oben
// ist davon bewusst ausgenommen
if (isAnyPanelOpen3d()) return;
let dx = 0, dy = 0;
if (keys["w"]) dy -= 5;
if (keys["s"]) dy += 5;
if (keys["a"]) dx -= 5;
if (keys["d"]) dx += 5;
isSwimming3d = isInWater3d(myX, myY);
if (dx !== 0 || dy !== 0) {
// Bewegung relativ zur Blickrichtung der Kamera, wie in echten 3D-Spielen üblich
const forward = new THREE.Vector3(Math.sin(camYaw), 0, Math.cos(camYaw));
// Vorzeichen umgedreht, weil die Kamera seit dem letzten Fix
// hinter statt vor der Laufrichtung sitzt - das dreht auch die
// Bildschirm-rechts/links-Zuordnung um
const right = new THREE.Vector3(-Math.cos(camYaw), 0, Math.sin(camYaw));
const moveVec = forward.multiplyScalar(-dy).add(right.multiplyScalar(dx));
moveVec.normalize();
// Im Wasser deutlich langsamer unterwegs (Schwimmen statt Laufen)
const speed = isSwimming3d ? 0.075 : 0.15;
const fullX = myX + moveVec.x * speed;
const fullY = myY + moveVec.z * speed;
// Einfaches Achsen-Sliding: volle Bewegung versuchen, sonst nur
// X oder nur Z (rutscht an Wänden/Häusern entlang statt hart
// stehenzubleiben) - deutlich angenehmer zu spielen
let newX = myX, newY = myY;
if (!isBlockedAnywhere3d(fullX, fullY)) {
newX = fullX; newY = fullY;
} else if (!isBlockedAnywhere3d(fullX, myY)) {
newX = fullX;
} else if (!isBlockedAnywhere3d(myX, fullY)) {
newY = fullY;
}
if (newX !== myX || newY !== myY) {
myMesh.rotation.y = Math.atan2(moveVec.x, moveVec.z);
// Lokal SOFORT übernehmen (Client-Vorhersage) statt auf die
// Server-Antwort zu warten - das macht die eigene Bewegung
// ruckelfrei, der Server bestätigt/korrigiert nur im Hintergrund
myX = newX;
myY = newY;
ws.send(JSON.stringify({ type: "move", x: newX * 32, y: newY * 32 }));
}
}
}
// -------------------------------------------------------------
// RENDER-LOOP
// -------------------------------------------------------------
function interpolateOtherPlayers() {
// sanft in Richtung der letzten bekannten Server-Position bewegen,
// statt hart dorthin zu springen - macht die Bewegung anderer
// Spieler flüssig, obwohl Updates nur ~10x/Sekunde reinkommen
for (const mesh of otherMeshes.values()) {
if (mesh.userData.targetX === undefined) continue;
mesh.position.x += (mesh.userData.targetX - mesh.position.x) * 0.2;
mesh.position.z += (mesh.userData.targetZ - mesh.position.z) * 0.2;
}
}
const animClock3d = new THREE.Clock();
// Lässt die Beine einer Figur im Schrittwechsel schwingen (nur solange
// isMoving true ist) - delta ist die seit dem letzten Frame vergangene
// Zeit in Sekunden, macht das Tempo unabhängig von der Bildrate
// -------------------------------------------------------------
// BLAULICHT + SIRENE: Ton wird per Web Audio synthetisiert (kein
// externer Sound nötig), Lautstärke richtet sich nach der Entfernung
// zur eigenen Spielfigur. Ein gemeinsamer AudioContext für alle
// Fahrzeuge mit aktiver Sirene gleichzeitig
// -------------------------------------------------------------
let sirenAudioCtx3d = null;
function ensureSirenAudioCtx3d() {
if (!sirenAudioCtx3d) sirenAudioCtx3d = new (window.AudioContext || window.webkitAudioContext)();
return sirenAudioCtx3d;
}
function startSiren3d(mesh) {
const ctx = ensureSirenAudioCtx3d();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = 500;
gain.gain.value = 0;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
mesh.userData.sirenOsc = osc;
mesh.userData.sirenGain = gain;
mesh.userData.sirenPhase = 0;
}
function stopSiren3d(mesh) {
if (mesh.userData.sirenOsc) {
try { mesh.userData.sirenOsc.stop(); } catch {}
mesh.userData.sirenOsc.disconnect();
mesh.userData.sirenGain.disconnect();
mesh.userData.sirenOsc = null;
mesh.userData.sirenGain = null;
}
}
function updateSirens3d(delta) {
for (const mesh of carMeshes.values()) {
const beacon = mesh.userData.beaconLight;
if (!beacon) continue;
if (mesh.userData.emergency) {
// Blaulicht: schnelles Rot/Blau-Blinken
const blinkPhase = Math.floor(Date.now() / 200) % 2;
beacon.color.setHex(blinkPhase === 0 ? 0x2255ff : 0xff2222);
beacon.intensity = 2.2;
if (!mesh.userData.sirenOsc) startSiren3d(mesh);
// Zweiton-Sirene (auf/ab pendelnde Frequenz), Lautstärke nach
// Entfernung zur eigenen Figur
mesh.userData.sirenPhase += delta * 3;
const freq = 500 + Math.sin(mesh.userData.sirenPhase) * 200;
mesh.userData.sirenOsc.frequency.setValueAtTime(freq, sirenAudioCtx3d.currentTime);
const dist = Math.hypot(mesh.position.x - myX, mesh.position.z - myY);
const volume = Math.max(0, 1 - dist / 30) * 0.15; // max. Hörweite ~30 Einheiten
mesh.userData.sirenGain.gain.setValueAtTime(volume, sirenAudioCtx3d.currentTime);
} else {
beacon.intensity = 0;
if (mesh.userData.sirenOsc) stopSiren3d(mesh);
}
}
}
function animateLegSwing3d(mesh, isMoving, delta, swimming) {
const parts = mesh.userData.parts;
if (!parts || !parts.legPivotLeft) return;
if (mesh.userData.emoteActive) return; // Emote-Pose hat Vorrang, keine Überschreibung
if (swimming) {
// Schwimmen: Beine nur sanft wedeln, Arme kreisen abwechselnd
// wie beim Kraulen - komplett andere Bewegung als beim Laufen
mesh.userData.walkPhase = (mesh.userData.walkPhase || 0) + delta * 6;
const kick = Math.sin(mesh.userData.walkPhase * 2) * 0.25;
parts.legPivotLeft.rotation.x = kick;
parts.legPivotRight.rotation.x = -kick;
if (parts.armPivotLeft) {
parts.armPivotLeft.rotation.x = Math.sin(mesh.userData.walkPhase) * 1.4 - 0.3;
parts.armPivotRight.rotation.x = Math.sin(mesh.userData.walkPhase + Math.PI) * 1.4 - 0.3;
}
return;
}
if (isMoving) {
mesh.userData.walkPhase = (mesh.userData.walkPhase || 0) + delta * 9;
const swing = Math.sin(mesh.userData.walkPhase) * 0.6;
parts.legPivotLeft.rotation.x = swing;
parts.legPivotRight.rotation.x = -swing;
// Arme schwingen gegengleich zu den Beinen (wie beim echten
// Gehen: rechter Arm nach vorn, wenn linkes Bein nach vorn geht)
if (parts.armPivotLeft) {
const armSwing = swing * 0.7;
parts.armPivotLeft.rotation.x = -armSwing;
parts.armPivotRight.rotation.x = armSwing;
}
} else {
// sanft in die Ruheposition zurückgleiten statt hart zu stoppen
parts.legPivotLeft.rotation.x *= 0.8;
parts.legPivotRight.rotation.x *= 0.8;
if (parts.armPivotLeft) {
parts.armPivotLeft.rotation.x *= 0.8;
parts.armPivotRight.rotation.x *= 0.8;
}
mesh.userData.walkPhase = 0;
}
}
function updateWalkAnimations3d(delta) {
const isWalking = (keys["w"] || keys["a"] || keys["s"] || keys["d"]) && !drivingCarId && !passengerCarId;
animateLegSwing3d(myMesh, isWalking, delta, isSwimming3d);
// andere Spieler: laufen, wenn ihre Zielposition spürbar von der
// aktuell dargestellten Position abweicht (kommt alle ~100ms neu rein)
for (const mesh of otherMeshes.values()) {
const dist = mesh.userData.targetX !== undefined
? Math.hypot(mesh.userData.targetX - mesh.position.x, mesh.userData.targetZ - mesh.position.z)
: 0;
animateLegSwing3d(mesh, dist > 0.02, delta, isInWater3d(mesh.position.x, mesh.position.z));
}
}
// Ersetzt eine bestehende Spielfigur/ein Auto durch eine frische Kopie
// des jetzt geladenen echten Modells, an derselben Position/Drehung
function upgradeMeshToRealModel3d(oldMesh, createFn, extraColor) {
const newMesh = createFn(extraColor);
newMesh.position.copy(oldMesh.position);
newMesh.rotation.copy(oldMesh.rotation);
newMesh.visible = oldMesh.visible;
// NUR das Interpolationsziel übernehmen - mixer/walkAction sind für
// das neue (echte) Modell frisch erzeugt und dürfen nicht überschrieben werden
if (oldMesh.userData.targetX !== undefined) {
newMesh.userData.targetX = oldMesh.userData.targetX;
newMesh.userData.targetZ = oldMesh.userData.targetZ;
}
scene.remove(oldMesh);
scene.add(newMesh);
return newMesh;
}
async function initRealModels3d() {
await preloadModels3d();
if (!preloadedModels.truck) return; // Laden fehlgeschlagen, bei Platzhaltern bleiben
for (const [id, mesh] of carMeshes) {
// Autos mit eigenem, selbstgebautem Modell NICHT mit dem
// Standard-Truck überschreiben
if (mesh.userData.hasCustomModel) continue;
carMeshes.set(id, upgradeMeshToRealModel3d(mesh, createCarMesh));
}
}
initRealModels3d();
// Bildrate über 3-Sekunden-Fenster mitzählen; bleibt sie anhaltend
// niedrig, wird EINMALIG auf die sparsame Qualitätsstufe umgeschaltet
// (nur abwärts - kein Hin-und-Her, das würde selbst ruckeln)
let fpsFrameCount3d = 0;
let fpsWindowStart3d = performance.now();
function monitorPerformance3d() {
fpsFrameCount3d++;
const now = performance.now();
const elapsed = now - fpsWindowStart3d;
if (elapsed < 3000) return;
const avgFps = (fpsFrameCount3d / elapsed) * 1000;
fpsFrameCount3d = 0;
fpsWindowStart3d = now;
if (!lowQualityMode3d && avgFps < 24) {
lowQualityMode3d = true;
applyQualityMode3d();
console.log("[Performance] Niedrige Bildrate erkannt (" + avgFps.toFixed(1) + " FPS) - Qualität reduziert.");
}
}
function animate() {
requestAnimationFrame(animate);
monitorPerformance3d();
const delta = animClock3d.getDelta();
activeMixers3d.forEach(m => m.update(delta));
updateWalkAnimations3d(delta);
updateSirens3d(delta);
// Wank-Effekt sanft an die Ziel-Neigung angleichen statt zu springen
for (const mesh of carMeshes.values()) {
const target = mesh.userData.targetRoll || 0;
mesh.rotation.z += (target - mesh.rotation.z) * Math.min(1, delta * 6);
}
// Wasser-Schimmer: leicht pulsierende Transparenz, sieht bewegt aus
// ohne eine echte Wellen-Animation bauen zu müssen
const shimmer = 0.75 + Math.sin(Date.now() / 600) * 0.1;
waterMeshes3d.forEach(mat => { mat.opacity = shimmer; });
updateMovement();
updateElevationAndFallDamage3d();
myMesh.position.set(myX, currentElevation3d, myY);
interpolateOtherPlayers();
updateInteractRing3d();
if (spectatorMode3d) {
updateSpectatorMovement3d(delta);
updateSpectatorCamera3d();
} else if (photoMode3d) {
updatePhotoModeMovement3d(delta);
updatePhotoModeCamera3d();
} else {
updateCamera();
}
renderer.render(scene, camera);
}
animate();
// -------------------------------------------------------------
// MOBILE TOUCH-STEUERUNG: nur eingeblendet, wenn ein Touch-Gerät
// erkannt wird (kein Umschalter nötig - Maus/Tastatur funktionieren
// auf Touch-Geräten meist eh nicht sinnvoll parallel)
// -------------------------------------------------------------
if ("ontouchstart" in window || navigator.maxTouchPoints > 0) {
document.body.classList.add("touch-device3d");
}
// Joystick: Finger auf die Zone legen, Richtung bestimmt simulierte
// WASD-Werte in "keys" - updateMovement() liest die exakt genauso
// wie bei echten Tasten, kein Extra-Code dort nötig
const joystickZone3d = document.getElementById("joystickZone3d");
const joystickKnob3d = document.getElementById("joystickKnob3d");
let joystickTouchId3d = null;
const JOYSTICK_MAX_DIST = 45;
joystickZone3d.addEventListener("touchstart", e => {
const t = e.changedTouches[0];
joystickTouchId3d = t.identifier;
e.preventDefault();
}, { passive: false });
joystickZone3d.addEventListener("touchmove", e => {
const t = [...e.changedTouches].find(t => t.identifier === joystickTouchId3d);
if (!t) return;
e.preventDefault();
const rect = joystickZone3d.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let dx = t.clientX - centerX;
let dy = t.clientY - centerY;
const dist = Math.min(JOYSTICK_MAX_DIST, Math.hypot(dx, dy));
const angle = Math.atan2(dy, dx);
dx = Math.cos(angle) * dist;
dy = Math.sin(angle) * dist;
joystickKnob3d.style.transform = `translate(${dx}px, ${dy}px)`;
// Schwellenwert von ~35% des Radius, bevor eine Richtung "zählt" -
// verhindert Zittern bei ganz kleinen Fingerbewegungen
const threshold = JOYSTICK_MAX_DIST * 0.35;
keys["w"] = dy < -threshold;
keys["s"] = dy > threshold;
keys["a"] = dx < -threshold;
keys["d"] = dx > threshold;
}, { passive: false });
function resetJoystick3d() {
joystickTouchId3d = null;
joystickKnob3d.style.transform = "translate(0, 0)";
keys["w"] = keys["a"] = keys["s"] = keys["d"] = false;
}
joystickZone3d.addEventListener("touchend", resetJoystick3d);
joystickZone3d.addEventListener("touchcancel", resetJoystick3d);
// Kamera-Wisch-Zone: rechte Bildschirmhälfte, Finger ziehen dreht die
// Kamera - dieselbe Logik wie die Maus-Steuerung, nur mit Touch-Events
const cameraTouchZone3d = document.getElementById("cameraTouchZone3d");
let camTouchId3d = null;
let camTouchLastX3d = 0, camTouchLastY3d = 0;
cameraTouchZone3d.addEventListener("touchstart", e => {
const t = e.changedTouches[0];
camTouchId3d = t.identifier;
camTouchLastX3d = t.clientX;
camTouchLastY3d = t.clientY;
e.preventDefault();
}, { passive: false });
cameraTouchZone3d.addEventListener("touchmove", e => {
const t = [...e.changedTouches].find(t => t.identifier === camTouchId3d);
if (!t) return;
e.preventDefault();
camYaw -= (t.clientX - camTouchLastX3d) * 0.006 * mouseSensitivity3d;
camPitch = Math.max(0.15, Math.min(1.4, camPitch - (t.clientY - camTouchLastY3d) * 0.006 * mouseSensitivity3d));
camTouchLastX3d = t.clientX;
camTouchLastY3d = t.clientY;
}, { passive: false });
cameraTouchZone3d.addEventListener("touchend", () => { camTouchId3d = null; });
cameraTouchZone3d.addEventListener("touchcancel", () => { camTouchId3d = null; });
// Aktions-Buttons - rufen dieselben Funktionen wie die Tastatur auf
document.getElementById("touchBtnE3d").addEventListener("touchstart", e => { e.preventDefault(); handleInteractKey3d(); });
document.getElementById("touchBtnI3d").addEventListener("touchstart", e => { e.preventDefault(); handleInventoryKey3d(); });
document.getElementById("touchBtnJ3d").addEventListener("touchstart", e => { e.preventDefault(); handleJobMenuKey3d(); });
</script>
</body>
</html>