4442 lines
191 KiB
HTML
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> |