2297 lines
87 KiB
HTML
2297 lines
87 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>3D-Editor (Prototyp)</title>
|
|
<style>
|
|
html, body { margin: 0; padding: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
|
|
#canvas3d { display: block; width: 100vw; height: 100vh; }
|
|
|
|
#toolbar {
|
|
position: fixed; top: 10px; left: 10px;
|
|
background: rgba(20,20,20,0.92); color: #eee; padding: 14px;
|
|
border-radius: 8px; font-size: 13px; width: 260px; z-index: 100;
|
|
border: 1px solid #444;
|
|
max-height: calc(100vh - 20px); overflow-y: auto; box-sizing: border-box;
|
|
}
|
|
#toolbar h3 { margin: 0 0 8px 0; color: #f0c419; }
|
|
#toolbar p.hint { color: #888; font-size: 11px; margin: 4px 0 10px 0; }
|
|
#toolbar h4 { margin: 12px 0 6px 0; color: #ccc; font-size: 12px; }
|
|
|
|
.palette-btn {
|
|
display: inline-flex; flex-direction: column; align-items: center;
|
|
width: 56px; padding: 8px 4px; margin: 3px; border-radius: 6px;
|
|
background: #2a2a2a; border: 2px solid #444; color: #ccc; cursor: pointer; font-size: 10px;
|
|
}
|
|
.palette-btn.active { border-color: #5b8def; background: #223355; color: white; }
|
|
.palette-btn .icon { font-size: 20px; margin-bottom: 2px; }
|
|
|
|
.tool-btn {
|
|
padding: 6px 10px; margin: 2px; border-radius: 4px; border: 1px solid #444;
|
|
background: #2a2a2a; color: #ccc; cursor: pointer; font-size: 12px;
|
|
}
|
|
.tool-btn.active { background: #5b8def; color: white; border-color: #5b8def; }
|
|
.tool-btn.danger { background: #7a2c2c; }
|
|
|
|
#jsonOutput {
|
|
width: 100%; box-sizing: border-box; height: 90px; background: #111;
|
|
color: #6fbf73; border: 1px solid #444; border-radius: 4px; font-size: 10px;
|
|
font-family: monospace; margin-top: 6px; resize: vertical;
|
|
}
|
|
|
|
#objectCount { position: fixed; bottom: 10px; left: 10px; color: #888; font-size: 12px; z-index: 100; }
|
|
.search-result-item3d {
|
|
padding: 5px 8px; cursor: pointer; border-radius: 4px; font-size: 12px;
|
|
display: flex; justify-content: space-between;
|
|
}
|
|
.search-result-item3d:hover { background: rgba(91,141,239,0.25); }
|
|
|
|
#builderModal {
|
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
background: rgba(0,0,0,0.85); z-index: 500; display: none;
|
|
align-items: center; justify-content: center;
|
|
}
|
|
#builderPanel {
|
|
background: #1a1a1a; border: 2px solid #f0c419; border-radius: 8px;
|
|
padding: 16px; width: 1300px; max-width: 96vw;
|
|
display: flex; gap: 14px; color: #ddd;
|
|
}
|
|
#builderCanvas { width: 950px; max-width: 70vw; height: 720px; max-height: 82vh; background: #2a2a35; border-radius: 6px; }
|
|
#builderSidebar { width: 260px; display: flex; flex-direction: column; gap: 8px; }
|
|
#builderSidebar h4 { margin: 0 0 4px 0; color: #f0c419; font-size: 13px; }
|
|
#builderPartsList { flex: 1; overflow-y: auto; max-height: 320px; font-size: 12px; }
|
|
.builder-part-row {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 5px 8px; margin-bottom: 3px; background: #2a2a2a; border-radius: 4px; cursor: pointer;
|
|
}
|
|
.builder-part-row.active { background: #5b3d10; }
|
|
.builder-part-row button { background: #7a2c2c; border: none; color: white; border-radius: 3px; cursor: pointer; font-size: 10px; }
|
|
#builderPartPalette { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
#builderName { width: 100%; box-sizing: border-box; padding: 6px; background: #14151a; border: 1px solid #444; color: #ddd; border-radius: 4px; margin-top: 4px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<canvas id="canvas3d"></canvas>
|
|
|
|
<div id="toolbar">
|
|
<h3>🧪 3D-Editor (Prototyp)</h3>
|
|
<p class="hint">WASD = Kamera bewegen, Q/E = hoch/runter, rechte Maustaste ziehen = umschauen. Objekt-Typ wählen, dann auf den Boden klicken zum Platzieren. Bestehendes Objekt anklicken zum Bearbeiten. Taste T = Testlauf umschalten.</p>
|
|
|
|
<button class="tool-btn" id="walkModeBtn" onclick="toggleWalkMode()" style="width:100%; background:#2c7a3d;">🚶 Testlauf starten</button>
|
|
|
|
<h4>Welt laden (echte Kartendaten)</h4>
|
|
<select id="worldSelect" style="width:100%; padding:4px;"></select>
|
|
<button class="tool-btn" onclick="loadSelectedWorld()" style="width:100%; margin-top:4px;">Boden laden</button>
|
|
<button class="tool-btn" onclick="loadObjectsForWorld()" style="width:100%; margin-top:4px;">Gespeicherte Objekte laden</button>
|
|
<button class="tool-btn" onclick="saveObjectsForWorld()" style="width:100%; margin-top:4px;">💾 Objekte auf Server speichern</button>
|
|
<div id="saveStatus" style="font-size:11px; color:#888; margin-top:4px;"></div>
|
|
|
|
<h4>Objekt-Palette</h4>
|
|
<div id="palette"></div>
|
|
|
|
<h4>Eigenes Objekt bauen</h4>
|
|
<button class="tool-btn" onclick="openObjectBuilder()" style="width:100%;">🔧 Baukasten öffnen</button>
|
|
<button class="tool-btn" onclick="openCarBuilder3d()" style="width:100%; margin-top:4px; background:#3a5aa0;">🚗 Auto bauen</button>
|
|
|
|
<h4>Werkzeug (für ausgewähltes Objekt)</h4>
|
|
<div id="toolButtons"></div>
|
|
<div style="margin-top:4px;">
|
|
<label style="font-size:11px; color:#aaa;"><input type="checkbox" id="snapToggle"> Am Raster einrasten</label>
|
|
</div>
|
|
<button class="tool-btn danger" onclick="deleteSelected()" style="display:block; width:100%; margin-top:6px;">Löschen (Entf)</button>
|
|
<p class="hint" style="margin-top:6px;">Strg+D = Duplizieren, Strg+Z = Rückgängig, Strg+Y = Wiederholen, Shift+Klick = Mehrfach-Auswahl, ESC = Auswahl aufheben</p>
|
|
|
|
<div id="portalLinkBox" style="display:none; margin-top:8px; padding:8px; background:#332a10; border-radius:6px;">
|
|
<h4 style="margin-top:0; color:#f0c419;">🌀 Portal-Verknüpfung</h4>
|
|
<select id="portalLinkSelect" style="width:100%; padding:4px;"></select>
|
|
<button class="tool-btn" onclick="linkSelectedPortal()" style="width:100%; margin-top:4px;">Verknüpfen</button>
|
|
</div>
|
|
|
|
<div id="signTextBox" style="display:none; margin-top:8px; padding:8px; background:#2a2a10; border-radius:6px;">
|
|
<h4 style="margin-top:0; color:#f0c419;">🪧 Schild-Text</h4>
|
|
<input type="text" id="signTextInput" placeholder="z.B. Krankenhaus 200m" style="width:100%; box-sizing:border-box; padding:4px;">
|
|
<button class="tool-btn" onclick="applySignText()" style="width:100%; margin-top:4px;">Übernehmen</button>
|
|
</div>
|
|
|
|
<div id="graffitiTextBox" style="display:none; margin-top:8px; padding:8px; background:#2a1a2a; border-radius:6px;">
|
|
<h4 style="margin-top:0; color:#e84393;">🎨 Graffiti-Text (z.B. Bande-Tag)</h4>
|
|
<input type="text" id="graffitiTextInput" placeholder="z.B. RedSkulls" style="width:100%; box-sizing:border-box; padding:4px;">
|
|
<div style="display:flex; align-items:center; gap:8px; margin-top:6px;">
|
|
<label style="font-size:11px; color:#aaa;">Sprühfarbe:</label>
|
|
<input type="color" id="graffitiColorInput" value="#e84393" style="width:44px; height:26px; padding:0; border:none; cursor:pointer;">
|
|
</div>
|
|
<button class="tool-btn" onclick="applyGraffitiText()" style="width:100%; margin-top:4px;">Übernehmen</button>
|
|
<p style="font-size:10px; color:#888; margin:6px 0 0;">Tipp: mit R/Rotationsgriff an eine Häuserwand drehen, dann etwas hineinschieben.</p>
|
|
</div>
|
|
|
|
<h4>Auswahl in andere Welt kopieren</h4>
|
|
<div style="display:flex; gap:6px;">
|
|
<select id="copyTargetWorldSelect" style="flex:1;"></select>
|
|
<button class="tool-btn" onclick="copySelectionToWorld3d()">Kopieren</button>
|
|
</div>
|
|
<p class="hint" id="copyStatusMsg3d" style="margin:4px 0 0;"></p>
|
|
|
|
<h4>Raster-Einrasten</h4>
|
|
<div style="display:flex; align-items:center; gap:8px;">
|
|
<input type="checkbox" id="gridSnapCheckbox" onchange="gridSnapEnabled3d = this.checked;">
|
|
<label for="gridSnapCheckbox" style="font-size:12px;">aktiv</label>
|
|
<input type="number" id="gridSnapSize" value="1" min="0.1" step="0.1" style="width:60px;" onchange="gridSnapSize3d = parseFloat(this.value) || 1;">
|
|
<span style="font-size:11px; color:#888;">Einheiten</span>
|
|
</div>
|
|
|
|
<h4>Objekt-Suche</h4>
|
|
<input type="text" id="objectSearchInput" placeholder="z.B. Wegweiser, Bank..." oninput="searchObjects3d(this.value)" style="width:100%; box-sizing:border-box;">
|
|
<div id="objectSearchResults" style="max-height:120px; overflow-y:auto; margin-top:4px;"></div>
|
|
|
|
<h4>Export / Import</h4>
|
|
<button class="tool-btn" onclick="exportJson()" style="width:100%;">Als JSON anzeigen</button>
|
|
<button class="tool-btn" onclick="importJson()" style="width:100%; margin-top:4px;">Aus JSON laden</button>
|
|
<textarea id="jsonOutput" placeholder="..."></textarea>
|
|
</div>
|
|
|
|
<div id="objectCount">0 Objekte</div>
|
|
|
|
<div id="builderModal">
|
|
<div id="builderPanel">
|
|
<canvas id="builderCanvas"></canvas>
|
|
<div id="builderSidebar">
|
|
<p style="color:#888; font-size:11px; margin:0 0 8px;">Maus ziehen = umschauen, Mausrad = zoomen</p>
|
|
<h4>Teil hinzufügen</h4>
|
|
<div id="builderPartPalette"></div>
|
|
|
|
<h4 style="margin-top:10px;">Teile (anklicken zum Bearbeiten)</h4>
|
|
<div id="builderPartsList"></div>
|
|
|
|
<h4>Werkzeug</h4>
|
|
<div style="display:flex; gap:4px;">
|
|
<button class="tool-btn" onclick="setBuilderMode('translate')">Verschieben</button>
|
|
<button class="tool-btn" onclick="setBuilderMode('scale')">Skalieren</button>
|
|
</div>
|
|
<div style="display:flex; align-items:center; gap:8px; margin-top:6px;">
|
|
<label style="font-size:11px; color:#aaa;">Farbe (Teil):</label>
|
|
<input type="color" id="builderPartColor" value="#5b8def" onchange="setBuilderPartColor3d(this.value)" style="width:44px; height:26px; padding:0; border:none; cursor:pointer;">
|
|
</div>
|
|
|
|
<h4>Name</h4>
|
|
<input type="text" id="builderName" placeholder="z.B. Mein Gebäude">
|
|
|
|
<h4 style="margin-top:10px;">Vorlage laden (überschreibt aktuellen Baukasten-Inhalt)</h4>
|
|
<div style="display:flex; gap:6px;">
|
|
<select id="builderTemplateSelect" style="flex:1;">
|
|
<option value="">- Vorlage wählen -</option>
|
|
<option value="Polizeiwagen">🚓 Polizeiwagen</option>
|
|
<option value="Krankenwagen">🚑 Krankenwagen</option>
|
|
<option value="Sportwagen">🏎️ Sportwagen</option>
|
|
</select>
|
|
<button class="tool-btn" onclick="loadBuilderTemplate3d()">Laden</button>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:6px; margin-top:10px;">
|
|
<button class="tool-btn" id="builderFinishBtn" onclick="finishObjectBuilder()" style="flex:1;">✅ Fertig, zur Palette</button>
|
|
<button class="tool-btn danger" onclick="closeObjectBuilder()">Abbrechen</button>
|
|
</div>
|
|
</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/controls/TransformControls.js"></script>
|
|
<script>
|
|
// -------------------------------------------------------------
|
|
// GRUNDAUFBAU
|
|
// -------------------------------------------------------------
|
|
const canvas = document.getElementById("canvas3d");
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x2a2a35);
|
|
|
|
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.set(15, 15, 15);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
window.addEventListener("resize", () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
sun.position.set(30, 50, 20);
|
|
sun.castShadow = true;
|
|
scene.add(sun);
|
|
|
|
// Boden: entweder einfaches Raster (Standard) oder echte Kacheldaten
|
|
// einer geladenen Welt (siehe loadSelectedWorld)
|
|
scene.add(new THREE.GridHelper(60, 60, 0x555577, 0x333344));
|
|
const groundPlaneGeo = new THREE.PlaneGeometry(4000, 4000);
|
|
const groundPlaneMat = new THREE.MeshBasicMaterial({ visible: false });
|
|
const groundPlane = new THREE.Mesh(groundPlaneGeo, groundPlaneMat);
|
|
groundPlane.rotation.x = -Math.PI / 2;
|
|
scene.add(groundPlane);
|
|
|
|
const realGroundGroup = new THREE.Group();
|
|
scene.add(realGroundGroup);
|
|
|
|
let tileConfigCache3d = {};
|
|
fetch("/api/tile_config")
|
|
.then(res => res.json())
|
|
.then(data => { tileConfigCache3d = data || {}; })
|
|
.catch(() => {});
|
|
|
|
function buildGroundFromTiles(tiles) {
|
|
while (realGroundGroup.children.length) realGroundGroup.remove(realGroundGroup.children[0]);
|
|
|
|
const geo = new THREE.BoxGeometry(1, 0.1, 1);
|
|
const rows = tiles.length;
|
|
const cols = tiles[0].length;
|
|
|
|
// Wie in game3d.html: nach tatsächlicher Kachel-ID gruppieren und die
|
|
// im Kachel-Editor konfigurierte Farbe nutzen (statt nur Straße/Gras)
|
|
const groups = {};
|
|
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];
|
|
const mat = new THREE.MeshLambertMaterial({
|
|
color: cfg?.isRoad ? 0x3a3a3a : (cfg?.color || 0x4a8f3c)
|
|
});
|
|
|
|
const mesh = new THREE.InstancedMesh(geo, mat, list.length);
|
|
mesh.receiveShadow = true;
|
|
list.forEach(([x, y], i) => {
|
|
// KEINE Zentrierung mehr - muss exakt dieselben rohen
|
|
// Kachel-Koordinaten nutzen wie game3d.html, sonst landen
|
|
// platzierte Objekte im Spiel an einer anderen Stelle als im
|
|
// Editor (0,0 = Kartenecke, nicht Kartenmitte)
|
|
dummy.position.set(x, 0, y);
|
|
dummy.updateMatrix();
|
|
mesh.setMatrixAt(i, dummy.matrix);
|
|
});
|
|
realGroundGroup.add(mesh);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// WELTAUSWAHL: echte Kartendaten vom Server laden (kein Login nötig,
|
|
// /api/get_maps ist ein öffentlicher Lese-Endpunkt)
|
|
// -------------------------------------------------------------
|
|
async function loadWorldList() {
|
|
const res = await fetch("/api/get_maps");
|
|
const data = await res.json();
|
|
const select = document.getElementById("worldSelect");
|
|
select.innerHTML = `<option value="">- kein Boden (leeres Raster) -</option>`;
|
|
|
|
const copySelect = document.getElementById("copyTargetWorldSelect");
|
|
copySelect.innerHTML = "";
|
|
|
|
Object.keys(data.maps || {}).forEach(name => {
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
select.appendChild(opt);
|
|
|
|
const copyOpt = document.createElement("option");
|
|
copyOpt.value = name;
|
|
copyOpt.textContent = name;
|
|
copySelect.appendChild(copyOpt);
|
|
});
|
|
}
|
|
loadWorldList();
|
|
|
|
async function loadSelectedWorld() {
|
|
const name = document.getElementById("worldSelect").value;
|
|
if (!name) {
|
|
while (realGroundGroup.children.length) realGroundGroup.remove(realGroundGroup.children[0]);
|
|
return;
|
|
}
|
|
const res = await fetch("/api/get_maps");
|
|
const data = await res.json();
|
|
const map = data.maps[name];
|
|
if (map && map.tiles) buildGroundFromTiles(map.tiles);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// SERVER-PERSISTENZ: Objekte einer Welt laden/speichern (Laden ist
|
|
// öffentlich, Speichern braucht denselben Login wie der 2D-Editor)
|
|
// -------------------------------------------------------------
|
|
const editorToken = localStorage.getItem("token");
|
|
const statusEl = () => document.getElementById("saveStatus");
|
|
|
|
async function loadObjectsForWorld() {
|
|
const world = document.getElementById("worldSelect").value;
|
|
if (!world) {
|
|
alert("Erst eine Welt auswählen.");
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`/api/get_3d_objects?world=${encodeURIComponent(world)}`);
|
|
const data = await res.json();
|
|
if (!data.ok) {
|
|
statusEl().textContent = "Fehler beim Laden: " + (data.error || "unbekannt");
|
|
return;
|
|
}
|
|
|
|
// aktuelle Objekte erst entfernen
|
|
transformControls.detach();
|
|
placedObjects.forEach(o => scene.remove(o.mesh));
|
|
placedObjects.length = 0;
|
|
selectedEntry = null;
|
|
|
|
data.objects.forEach(row => {
|
|
// custom_-Typen (selbstgebaute Objekte) existieren nur in DIESER
|
|
// Editor-Sitzung als Bauplan - beim Laden aus einer neuen Sitzung
|
|
// registrieren wir sie anhand des mitgespeicherten Bauplans neu
|
|
if (row.type.startsWith("custom_") && !PALETTE.find(p => p.type === row.type)) {
|
|
PALETTE.push({
|
|
type: row.type,
|
|
icon: "🔧",
|
|
label: row.type,
|
|
color: 0x5b8def,
|
|
baseY: 0,
|
|
isCustom: true,
|
|
// MySQL liefert JSON-Spalten schon als fertiges Objekt zurück
|
|
blueprint: row.blueprint ? (typeof row.blueprint === "string" ? JSON.parse(row.blueprint) : row.blueprint).map(bp => ({
|
|
type: bp.type,
|
|
position: new THREE.Vector3(bp.position.x, bp.position.y, bp.position.z),
|
|
scale: new THREE.Vector3(bp.scale.x, bp.scale.y, bp.scale.z),
|
|
rotation: new THREE.Euler(bp.rotation._x, bp.rotation._y, bp.rotation._z),
|
|
color: bp.color || "#5b8def"
|
|
})) : []
|
|
});
|
|
}
|
|
if (!PALETTE.find(p => p.type === row.type)) return; // unbekannter Typ (z.B. gelöschter custom-Typ) überspringen
|
|
|
|
const entry = placeObject(row.type, new THREE.Vector3(Number(row.x), Number(row.y), Number(row.z)), true);
|
|
entry.mesh.position.set(Number(row.x), Number(row.y), Number(row.z));
|
|
entry.mesh.rotation.y = Number(row.rotation_y) || 0;
|
|
const s = Number(row.scale) || 1;
|
|
entry.mesh.scale.set(s, s, s);
|
|
if (row.type === "portal" || row.type === "elevator") {
|
|
entry.mesh.userData.portalId = row.portal_id;
|
|
entry.mesh.userData.linkedTo = row.linked_to;
|
|
nextPortalId = Math.max(nextPortalId, (row.portal_id || 0) + 1);
|
|
}
|
|
if (row.type === "signpost" && row.label_text) {
|
|
entry.mesh.userData.labelText = row.label_text;
|
|
const sprite = makeTextSprite3d(row.label_text);
|
|
sprite.position.y = 1.0;
|
|
sprite.userData.isSignLabel = true;
|
|
entry.mesh.add(sprite);
|
|
}
|
|
if (row.type === "graffiti" && row.label_text) {
|
|
// Format "farbe|text" - kein eigener DB-Spalten-Bedarf, nutzt
|
|
// dasselbe label_text-Feld wie der Wegweiser
|
|
const [color, ...rest] = row.label_text.split("|");
|
|
const text = rest.join("|");
|
|
entry.mesh.userData.graffitiColor = color;
|
|
entry.mesh.userData.labelText = text;
|
|
paintGraffitiTexture3d(entry.mesh, text, color);
|
|
}
|
|
});
|
|
|
|
rebuildPaletteUI();
|
|
updateObjectCount();
|
|
statusEl().textContent = `${data.objects.length} Objekte geladen.`;
|
|
}
|
|
|
|
async function saveObjectsForWorld() {
|
|
const world = document.getElementById("worldSelect").value;
|
|
if (!world) {
|
|
alert("Erst eine Welt auswählen, in die gespeichert werden soll.");
|
|
return;
|
|
}
|
|
if (!editorToken) {
|
|
alert("Zum Speichern musst du eingeloggt sein (mit Karten-Bearbeitungsrecht). Logg dich über /index.html ein und öffne diese Seite erneut.");
|
|
return;
|
|
}
|
|
|
|
const objects = placedObjects.map(o => {
|
|
const def = PALETTE.find(p => p.type === o.type);
|
|
return {
|
|
type: o.type,
|
|
x: o.mesh.position.x,
|
|
y: o.mesh.position.y,
|
|
z: o.mesh.position.z,
|
|
rotationY: o.mesh.rotation.y,
|
|
scale: o.mesh.scale.x,
|
|
blueprint: def?.isCustom ? def.blueprint : null,
|
|
portalId: (o.type === "portal" || o.type === "elevator") ? o.mesh.userData.portalId : null,
|
|
linkedTo: (o.type === "portal" || o.type === "elevator") ? o.mesh.userData.linkedTo : null,
|
|
labelText: o.type === "signpost" ? (o.mesh.userData.labelText || null)
|
|
: o.type === "graffiti" ? `${o.mesh.userData.graffitiColor || "#e84393"}|${o.mesh.userData.labelText || ""}`
|
|
: null
|
|
};
|
|
});
|
|
|
|
statusEl().textContent = "Speichere...";
|
|
const res = await fetch("/api/save_3d_objects", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + editorToken },
|
|
body: JSON.stringify({ world, objects })
|
|
});
|
|
const data = await res.json();
|
|
|
|
statusEl().textContent = data.ok
|
|
? `✅ ${data.count} Objekte gespeichert.`
|
|
: "❌ Fehler: " + (data.error || "unbekannt");
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// KAMERA-STEUERUNG: freie Flug-Kamera - WASD bewegt in Blickrichtung,
|
|
// rechte Maustaste ziehen dreht die Blickrichtung, Mausrad passt die
|
|
// Geschwindigkeit an. Deutlich praktischer zum Bauen als eine starre
|
|
// Orbit-Kamera, die immer nur um den Ursprung kreist.
|
|
// -------------------------------------------------------------
|
|
camera.position.set(15, 12, 15);
|
|
let camYaw = -2.35; // schaut anfangs zum Ursprung
|
|
let camPitch = -0.4;
|
|
let flySpeed = 0.25;
|
|
let isLooking = false;
|
|
let lastMouseX = 0, lastMouseY = 0;
|
|
const camKeys = {};
|
|
|
|
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
|
canvas.addEventListener("mousedown", e => {
|
|
if (e.button === 2) { isLooking = true; lastMouseX = e.clientX; lastMouseY = e.clientY; }
|
|
});
|
|
window.addEventListener("mouseup", () => { isLooking = false; });
|
|
window.addEventListener("mousemove", e => {
|
|
if (!isLooking) return;
|
|
// dieselbe Empfindlichkeits-Einstellung wie im Spiel (game3d.html),
|
|
// über denselben localStorage-Schlüssel geteilt
|
|
const sens = parseFloat(localStorage.getItem("mouseSensitivity3d")) || 1.0;
|
|
camYaw -= (e.clientX - lastMouseX) * 0.006 * sens;
|
|
camPitch = Math.max(-1.5, Math.min(1.5, camPitch - (e.clientY - lastMouseY) * 0.006 * sens));
|
|
lastMouseX = e.clientX;
|
|
lastMouseY = e.clientY;
|
|
});
|
|
canvas.addEventListener("wheel", e => {
|
|
flySpeed = Math.max(0.05, Math.min(1.5, flySpeed - e.deltaY * 0.0005));
|
|
});
|
|
|
|
// WASD nur hier für die Kamera nutzen (Q/E für hoch/runter) - eigenes
|
|
// Key-Set, damit es nicht mit anderen Tastenkürzeln kollidiert
|
|
document.addEventListener("keydown", e => { camKeys[e.key.toLowerCase()] = true; });
|
|
document.addEventListener("keyup", e => { camKeys[e.key.toLowerCase()] = false; });
|
|
|
|
function updateCamera() {
|
|
const forward = new THREE.Vector3(
|
|
Math.sin(camYaw) * Math.cos(camPitch),
|
|
Math.sin(camPitch),
|
|
Math.cos(camYaw) * Math.cos(camPitch)
|
|
);
|
|
const right = new THREE.Vector3(Math.cos(camYaw), 0, -Math.sin(camYaw));
|
|
|
|
// Nicht bewegen, während man gerade in ein Textfeld tippt (Weltname etc.)
|
|
const typing = document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
|
|
|
|
if (walkModeActive) {
|
|
updateWalkMode(typing);
|
|
} else if (!typing) {
|
|
if (camKeys["w"]) camera.position.addScaledVector(forward, flySpeed);
|
|
if (camKeys["s"]) camera.position.addScaledVector(forward, -flySpeed);
|
|
if (camKeys["a"]) camera.position.addScaledVector(right, -flySpeed);
|
|
if (camKeys["d"]) camera.position.addScaledVector(right, flySpeed);
|
|
if (camKeys["q"]) camera.position.y -= flySpeed;
|
|
if (camKeys["e"]) camera.position.y += flySpeed;
|
|
}
|
|
|
|
if (!walkModeActive) camera.lookAt(camera.position.clone().add(forward));
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// TESTLAUF-MODUS: statt frei zu fliegen läuft eine Spielfigur auf dem
|
|
// Boden und kollidiert mit den platzierten Objekten - genau wie im
|
|
// echten 3D-Testclient (game3d.html), damit man direkt im Editor
|
|
// prüfen kann, wie sich die eigene Bauten "anfühlen"
|
|
// -------------------------------------------------------------
|
|
let walkModeActive = false;
|
|
let walkerMesh = null;
|
|
let walkerX = 0, walkerZ = 0;
|
|
const WALKER_COLLISION_RADIUS = 0.3;
|
|
|
|
function createWalkerMesh() {
|
|
const group = new THREE.Group();
|
|
const body = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.28, 0.32, 0.9, 10),
|
|
new THREE.MeshLambertMaterial({ color: 0x5b8def })
|
|
);
|
|
body.position.y = 0.6;
|
|
group.add(body);
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 12), new THREE.MeshLambertMaterial({ color: 0xf0c090 }));
|
|
head.position.y = 1.25;
|
|
group.add(head);
|
|
return group;
|
|
}
|
|
|
|
function toggleWalkMode() {
|
|
walkModeActive = !walkModeActive;
|
|
const btn = document.getElementById("walkModeBtn");
|
|
|
|
if (walkModeActive) {
|
|
// beim Start: Auswahl aufheben, Kamera-Position als Lauf-Startpunkt übernehmen
|
|
transformControls.detach();
|
|
selectedEntry = null;
|
|
updatePortalLinkBox();
|
|
|
|
walkerX = camera.position.x;
|
|
walkerZ = camera.position.z;
|
|
|
|
if (!walkerMesh) {
|
|
walkerMesh = createWalkerMesh();
|
|
scene.add(walkerMesh);
|
|
}
|
|
walkerMesh.visible = true;
|
|
|
|
btn.textContent = "🔧 Testlauf beenden";
|
|
btn.style.background = "#7a2c2c";
|
|
} else {
|
|
if (walkerMesh) walkerMesh.visible = false;
|
|
btn.textContent = "🚶 Testlauf starten";
|
|
btn.style.background = "#2c7a3d";
|
|
}
|
|
}
|
|
|
|
function isBlockedByPlacedObjects(x, z) {
|
|
for (const entry of placedObjects) {
|
|
const def = PALETTE.find(p => p.type === entry.type);
|
|
let radius = def?.collisionRadius;
|
|
|
|
// Brücke/Tunnel sind zum Durchfahren/-laufen gedacht - die
|
|
// automatische Bounding-Box-Kollision würde sie sonst komplett
|
|
// unpassierbar machen
|
|
if (entry.type === "custom_bridge" || entry.type === "custom_tunnel" || entry.type === "custom_pier" || entry.type === "custom_sportscourt") continue;
|
|
|
|
if (!radius && entry.type.startsWith("custom_")) {
|
|
const box = new THREE.Box3().setFromObject(entry.mesh);
|
|
const size = box.getSize(new THREE.Vector3());
|
|
radius = Math.max(size.x, size.z) / 2;
|
|
}
|
|
if (!radius) continue; // Portale/Wasser/unbekannt - durchlaufbar
|
|
|
|
const scaledRadius = radius * (entry.mesh.scale.x || 1);
|
|
if (Math.hypot(x - entry.mesh.position.x, z - entry.mesh.position.z) < scaledRadius + WALKER_COLLISION_RADIUS) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function updateWalkMode(typing) {
|
|
const WALK_SPEED = 0.1;
|
|
let dx = 0, dz = 0;
|
|
if (!typing) {
|
|
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 (camKeys["w"]) { dx -= forward.x; dz -= forward.z; }
|
|
if (camKeys["s"]) { dx += forward.x; dz += forward.z; }
|
|
if (camKeys["a"]) { dx -= right.x; dz -= right.z; }
|
|
if (camKeys["d"]) { dx += right.x; dz += right.z; }
|
|
}
|
|
|
|
if (dx !== 0 || dz !== 0) {
|
|
const len = Math.hypot(dx, dz);
|
|
dx = dx / len * WALK_SPEED;
|
|
dz = dz / len * WALK_SPEED;
|
|
|
|
const fullX = walkerX + dx, fullZ = walkerZ + dz;
|
|
// Achsen-Sliding wie im Testclient: an Wänden entlangrutschen statt hart stoppen
|
|
if (!isBlockedByPlacedObjects(fullX, fullZ)) {
|
|
walkerX = fullX; walkerZ = fullZ;
|
|
} else if (!isBlockedByPlacedObjects(fullX, walkerZ)) {
|
|
walkerX = fullX;
|
|
} else if (!isBlockedByPlacedObjects(walkerX, fullZ)) {
|
|
walkerZ = fullZ;
|
|
}
|
|
walkerMesh.rotation.y = Math.atan2(dx, dz);
|
|
}
|
|
|
|
walkerMesh.position.set(walkerX, 0, walkerZ);
|
|
|
|
// Drittperson-Kamera hinter der Figur, wie im echten Spiel
|
|
const dist = 5;
|
|
camera.position.set(
|
|
walkerX + Math.sin(camYaw) * dist * Math.cos(camPitch),
|
|
1.5 + dist * Math.sin(camPitch),
|
|
walkerZ + Math.cos(camYaw) * dist * Math.cos(camPitch)
|
|
);
|
|
camera.lookAt(walkerX, 1, walkerZ);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// OBJEKT-PALETTE (Platzhalter-Formen - stehen später stellvertretend
|
|
// für "Haus", "Baum" usw., bis es echte Modelle gibt)
|
|
// -------------------------------------------------------------
|
|
const PALETTE = [
|
|
{ type: "house", icon: "🏠", label: "Haus", color: 0xc0784a, geo: () => new THREE.BoxGeometry(2, 2, 2), baseY: 1, collisionRadius: 1.3 },
|
|
{ type: "tree", icon: "🌲", label: "Baum", color: 0x2e7d32, geo: () => new THREE.ConeGeometry(0.8, 2, 8), baseY: 1, collisionRadius: 0.6 },
|
|
{ type: "lamp", icon: "💡", label: "Laterne", color: 0xd4c060, geo: () => new THREE.CylinderGeometry(0.1, 0.1, 2.2, 8), baseY: 1.1, collisionRadius: 0.15 },
|
|
{ type: "bush", icon: "🌿", label: "Busch", color: 0x4caf50, geo: () => new THREE.SphereGeometry(0.6, 10, 10), baseY: 0.6, collisionRadius: 0.6 },
|
|
{ type: "wall", icon: "🧱", label: "Mauer", color: 0x8a8a8a, geo: () => new THREE.BoxGeometry(2, 1.2, 0.3), baseY: 0.6, collisionRadius: 1.1 },
|
|
{ type: "car", icon: "🚗", label: "Auto", color: 0x3a5aa0, geo: () => new THREE.BoxGeometry(1.8, 0.7, 0.9), baseY: 0.35, collisionRadius: 1.0 },
|
|
{ type: "barrier", icon: "🚧", label: "Absperrung", color: 0xd47a2c, geo: () => new THREE.BoxGeometry(1.2, 0.8, 0.15), baseY: 0.4, collisionRadius: 0.7 },
|
|
{ type: "water", icon: "💧", label: "Wasser", color: 0x2e6fa0, geo: () => new THREE.BoxGeometry(3, 0.05, 3), baseY: 0.02 },
|
|
{ type: "portal", icon: "🌀", label: "Portal", color: 0xf0c419, geo: () => new THREE.TorusGeometry(0.9, 0.15, 12, 24), baseY: 1.0, rotateX: true },
|
|
{ type: "elevator", icon: "🛗", label: "Fahrstuhl", color: 0x7f8c8d, geo: () => new THREE.BoxGeometry(1.3, 2.4, 1.3), baseY: 1.2, collisionRadius: 0.75 }, // nutzt dieselbe portalId/linkedTo-Verknüpfung wie Portale, nur andere Optik
|
|
{ type: "signpost", icon: "🪧", label: "Wegweiser", color: 0x8b5a2b, geo: () => new THREE.BoxGeometry(0.08, 1.6, 0.08), baseY: 0.8, collisionRadius: 0.15 },
|
|
{ type: "graffiti", icon: "🎨", label: "Graffiti", color: 0x333333, geo: () => new THREE.BoxGeometry(2.2, 1.4, 0.05), baseY: 1.0 } // keine Kollision - wird an eine Wand gestellt/gedreht
|
|
];
|
|
let selectedPaletteType = null;
|
|
|
|
// -------------------------------------------------------------
|
|
// EIGENE PALETTE-OBJEKTE DAUERHAFT MERKEN (localStorage) - sonst
|
|
// wären alle per JSON importierten oder im Baukasten gebauten Typen
|
|
// nach einem Neuladen der Seite wieder weg und müssten jedes Mal neu
|
|
// eingefügt werden
|
|
// -------------------------------------------------------------
|
|
const CUSTOM_PALETTE_STORAGE_KEY = "editor3d_custom_palette";
|
|
|
|
function saveCustomPaletteToStorage3d() {
|
|
const customEntries = PALETTE.filter(p => p.isCustom).map(p => ({
|
|
type: p.type,
|
|
label: p.label,
|
|
blueprint: p.blueprint.map(bp => ({
|
|
type: bp.type,
|
|
position: { x: bp.position.x, y: bp.position.y, z: bp.position.z },
|
|
scale: { x: bp.scale.x, y: bp.scale.y, z: bp.scale.z },
|
|
rotation: { x: bp.rotation.x, y: bp.rotation.y, z: bp.rotation.z },
|
|
color: bp.color
|
|
}))
|
|
}));
|
|
try {
|
|
localStorage.setItem(CUSTOM_PALETTE_STORAGE_KEY, JSON.stringify(customEntries));
|
|
} catch (err) {
|
|
console.error("[Palette] Konnte eigene Objekte nicht dauerhaft speichern:", err);
|
|
}
|
|
}
|
|
|
|
function loadCustomPaletteFromStorage3d() {
|
|
let saved;
|
|
try {
|
|
saved = JSON.parse(localStorage.getItem(CUSTOM_PALETTE_STORAGE_KEY) || "[]");
|
|
} catch {
|
|
return;
|
|
}
|
|
let highestCounter = 0;
|
|
|
|
saved.forEach(item => {
|
|
if (PALETTE.find(p => p.type === item.type)) return; // schon vorhanden
|
|
PALETTE.push({
|
|
type: item.type,
|
|
icon: "🔧",
|
|
label: item.label || item.type,
|
|
color: 0x5b8def,
|
|
baseY: 0,
|
|
isCustom: true,
|
|
blueprint: item.blueprint.map(bp => ({
|
|
type: bp.type,
|
|
position: new THREE.Vector3(bp.position.x, bp.position.y, bp.position.z),
|
|
scale: new THREE.Vector3(bp.scale.x, bp.scale.y, bp.scale.z),
|
|
rotation: new THREE.Euler(bp.rotation.x, bp.rotation.y, bp.rotation.z),
|
|
color: bp.color
|
|
}))
|
|
});
|
|
const num = parseInt(item.type.replace("custom_", ""), 10);
|
|
if (!isNaN(num)) highestCounter = Math.max(highestCounter, num);
|
|
});
|
|
|
|
if (highestCounter >= customPaletteCounter) customPaletteCounter = highestCounter + 1;
|
|
if (saved.length > 0) rebuildPaletteUI();
|
|
}
|
|
rebuildPaletteUI(); // baut die Palette-Leiste auf (Funktion weiter unten definiert, aber als "function" gehoisted verfügbar)
|
|
|
|
// -------------------------------------------------------------
|
|
// PLATZIERUNG + AUSWAHL PER RAYCASTING
|
|
// -------------------------------------------------------------
|
|
const placedObjects = []; // { mesh, type }
|
|
const undoStack = [];
|
|
const redoStack = [];
|
|
let nextPortalId = 1;
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouseNdc = new THREE.Vector2();
|
|
|
|
function updateObjectCount() {
|
|
document.getElementById("objectCount").textContent = `${placedObjects.length} Objekte`;
|
|
}
|
|
|
|
canvas.addEventListener("click", e => {
|
|
if (walkModeActive) return; // während des Testlaufs nichts platzieren/auswählen
|
|
|
|
mouseNdc.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
mouseNdc.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
raycaster.setFromCamera(mouseNdc, camera);
|
|
|
|
// Erst prüfen, ob ein bestehendes Objekt getroffen wurde (Auswahl hat Vorrang)
|
|
// "true" = auch in Gruppen (selbstgebaute Mehrteil-Objekte) hineinschauen
|
|
const objectHits = raycaster.intersectObjects(placedObjects.map(o => o.mesh), true);
|
|
if (objectHits.length > 0) {
|
|
// bei Gruppen trifft der Strahl ein einzelnes TEIL - von dort aus
|
|
// nach oben zum eigentlich platzierten Objekt hochlaufen
|
|
let hit = objectHits[0].object;
|
|
while (hit && !placedObjects.find(o => o.mesh === hit)) hit = hit.parent;
|
|
if (hit) {
|
|
const entry = placedObjects.find(o => o.mesh === hit);
|
|
if (e.shiftKey) {
|
|
// Shift-Klick: zur Mehrfach-Auswahl hinzufügen/daraus entfernen,
|
|
// statt die normale Einzel-Auswahl zu ersetzen
|
|
const idx = selectedEntries.indexOf(entry);
|
|
if (idx >= 0) {
|
|
selectedEntries.splice(idx, 1);
|
|
} else {
|
|
selectedEntries.push(entry);
|
|
}
|
|
updateSelectionHighlight3d();
|
|
// Gizmo folgt immer dem zuletzt angeklickten Objekt der Auswahl
|
|
selectedEntry = selectedEntries[selectedEntries.length - 1] || null;
|
|
if (selectedEntry) transformControls.attach(selectedEntry.mesh);
|
|
else transformControls.detach();
|
|
updatePortalLinkBox();
|
|
updateSignTextBox();
|
|
} else {
|
|
selectObject(entry);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Sonst: falls ein Palette-Typ gewählt ist, auf dem Boden platzieren
|
|
if (!selectedPaletteType) return;
|
|
const groundHits = raycaster.intersectObject(groundPlane);
|
|
if (groundHits.length === 0) return;
|
|
|
|
const placePoint = groundHits[0].point.clone();
|
|
if (gridSnapEnabled3d) {
|
|
placePoint.x = snapToGrid3d(placePoint.x);
|
|
placePoint.z = snapToGrid3d(placePoint.z);
|
|
}
|
|
|
|
try {
|
|
placeObject(selectedPaletteType, placePoint);
|
|
} catch (err) {
|
|
console.error("[Platzieren] Fehler:", err);
|
|
alert("Fehler beim Platzieren: " + err.message + " (Details in der Browser-Konsole, F12)");
|
|
}
|
|
});
|
|
|
|
function placeObject(type, point, skipUndo) {
|
|
const def = PALETTE.find(p => p.type === type);
|
|
let mesh;
|
|
|
|
if (def.isCustom) {
|
|
// selbstgebautes Objekt: aus dem gemerkten Bauplan eine frische
|
|
// Gruppe zusammensetzen (jedes platzierte Exemplar bekommt eigene,
|
|
// unabhängige Meshes - kein geteiltes Objekt)
|
|
mesh = new THREE.Group();
|
|
def.blueprint.forEach(part => {
|
|
const prim = BUILDER_PRIMITIVES.find(bp => bp.type === part.type);
|
|
if (!prim) return; // unbekannter Grundform-Typ - überspringen statt abzustürzen
|
|
const partMesh = new THREE.Mesh(prim.geo(), new THREE.MeshLambertMaterial({ color: part.color || 0x5b8def }));
|
|
partMesh.position.copy(part.position);
|
|
partMesh.scale.copy(part.scale);
|
|
partMesh.rotation.copy(part.rotation);
|
|
partMesh.castShadow = true;
|
|
mesh.add(partMesh);
|
|
});
|
|
} else {
|
|
const mat = new THREE.MeshLambertMaterial({ color: def.color, emissive: type === "portal" ? 0x664400 : 0x000000 });
|
|
mesh = new THREE.Mesh(def.geo(), mat);
|
|
if (def.rotateX) mesh.rotation.x = Math.PI / 2; // Portal-Ring aufrecht statt flach liegend
|
|
mesh.castShadow = true;
|
|
}
|
|
|
|
const snap = document.getElementById("snapToggle").checked;
|
|
const px = snap ? Math.round(point.x) : point.x;
|
|
const pz = snap ? Math.round(point.z) : point.z;
|
|
|
|
mesh.position.set(px, def.baseY, pz);
|
|
mesh.userData.objType = type;
|
|
if (type === "portal" || type === "elevator") {
|
|
mesh.userData.portalId = nextPortalId++;
|
|
mesh.userData.linkedTo = null;
|
|
}
|
|
scene.add(mesh);
|
|
const entry = { mesh, type };
|
|
placedObjects.push(entry);
|
|
if (!skipUndo) { undoStack.push({ action: "add", entry }); redoStack.length = 0; }
|
|
updateObjectCount();
|
|
return entry;
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// TRANSFORM-CONTROLS: Verschieben/Drehen/Skalieren des ausgewählten
|
|
// Objekts (fertiges Three.js-Addon, kein Eigenbau nötig)
|
|
// -------------------------------------------------------------
|
|
const transformControls = new THREE.TransformControls(camera, renderer.domElement);
|
|
scene.add(transformControls);
|
|
let selectedEntry = null;
|
|
let selectedEntries = []; // Mehrfach-Auswahl (Shift-Klick) - selectedEntry bleibt das "primäre" Objekt fürs Gizmo/Text-Boxen
|
|
|
|
// Gelbe Umriss-Box je mehrfach-ausgewähltem Objekt
|
|
const selectionHighlightGroup3d = new THREE.Group();
|
|
scene.add(selectionHighlightGroup3d);
|
|
function updateSelectionHighlight3d() {
|
|
while (selectionHighlightGroup3d.children.length) selectionHighlightGroup3d.remove(selectionHighlightGroup3d.children[0]);
|
|
selectedEntries.forEach(entry => {
|
|
const box = new THREE.Box3().setFromObject(entry.mesh);
|
|
const helper = new THREE.Box3Helper(box, 0xf0c419);
|
|
selectionHighlightGroup3d.add(helper);
|
|
});
|
|
}
|
|
|
|
// Während man am Gizmo zieht, soll die rechte-Maustaste-Kamera-Drehung
|
|
// nicht gleichzeitig greifen
|
|
transformControls.addEventListener("dragging-changed", e => {
|
|
isLooking = isLooking && !e.value;
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// RASTER-EINRASTEN: rundet Position beim Platzieren UND beim
|
|
// Verschieben mit dem Gizmo auf ein Vielfaches der Rastergröße
|
|
// -------------------------------------------------------------
|
|
let gridSnapEnabled3d = false;
|
|
let gridSnapSize3d = 1;
|
|
function snapToGrid3d(value) {
|
|
return Math.round(value / gridSnapSize3d) * gridSnapSize3d;
|
|
}
|
|
transformControls.addEventListener("objectChange", () => {
|
|
if (!gridSnapEnabled3d || !transformControls.object) return;
|
|
if (transformControls.getMode() !== "translate") return;
|
|
const obj = transformControls.object;
|
|
obj.position.x = snapToGrid3d(obj.position.x);
|
|
obj.position.z = snapToGrid3d(obj.position.z);
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// OBJEKT-SUCHE: durchsucht Palette-Label, Typ und (falls vorhanden)
|
|
// Wegweiser-/Graffiti-Text der aktuell platzierten Objekte
|
|
// -------------------------------------------------------------
|
|
// -------------------------------------------------------------
|
|
// AUSWAHL IN ANDERE WELT KOPIEREN: sendet die aktuell (mehrfach-)
|
|
// ausgewählten Objekte per copy_3d_objects - ändert die aktuelle Welt
|
|
// selbst NICHT, fügt nur in der Zielwelt neue Zeilen hinzu
|
|
// -------------------------------------------------------------
|
|
async function copySelectionToWorld3d() {
|
|
const source = selectedEntries.length > 0 ? selectedEntries : (selectedEntry ? [selectedEntry] : []);
|
|
const statusEl = document.getElementById("copyStatusMsg3d");
|
|
|
|
if (source.length === 0) {
|
|
statusEl.textContent = "Erst mindestens ein Objekt auswählen.";
|
|
return;
|
|
}
|
|
const targetWorld = document.getElementById("copyTargetWorldSelect").value;
|
|
if (!targetWorld) {
|
|
statusEl.textContent = "Keine Zielwelt verfügbar.";
|
|
return;
|
|
}
|
|
if (!editorToken) {
|
|
alert("Zum Kopieren musst du eingeloggt sein (mit Karten-Bearbeitungsrecht).");
|
|
return;
|
|
}
|
|
|
|
const objects = source.map(entry => {
|
|
const def = PALETTE.find(p => p.type === entry.type);
|
|
return {
|
|
type: entry.type,
|
|
x: entry.mesh.position.x, y: entry.mesh.position.y, z: entry.mesh.position.z,
|
|
rotationY: entry.mesh.rotation.y,
|
|
scale: entry.mesh.scale.x,
|
|
blueprint: def?.isCustom ? def.blueprint : null,
|
|
labelText: entry.mesh.userData.labelText || null
|
|
};
|
|
});
|
|
|
|
try {
|
|
const res = await fetch("/api/copy_3d_objects", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + editorToken },
|
|
body: JSON.stringify({ targetWorld, objects })
|
|
});
|
|
const data = await res.json();
|
|
statusEl.textContent = data.ok
|
|
? `✅ ${data.count} Objekt(e) nach "${targetWorld}" kopiert.`
|
|
: `❌ ${data.error}`;
|
|
} catch {
|
|
statusEl.textContent = "❌ Anfrage fehlgeschlagen.";
|
|
}
|
|
}
|
|
|
|
function searchObjects3d(query) {
|
|
const el = document.getElementById("objectSearchResults");
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) { el.innerHTML = ""; return; }
|
|
|
|
const matches = placedObjects.filter(entry => {
|
|
const def = PALETTE.find(p => p.type === entry.type);
|
|
const label = (def?.label || entry.type).toLowerCase();
|
|
const extraText = (entry.mesh.userData.labelText || "").toLowerCase();
|
|
return label.includes(q) || entry.type.toLowerCase().includes(q) || extraText.includes(q);
|
|
});
|
|
|
|
if (matches.length === 0) {
|
|
el.innerHTML = `<p style="color:#888; font-size:11px; margin:4px 0;">Keine Treffer.</p>`;
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = matches.slice(0, 30).map((entry, i) => {
|
|
const def = PALETTE.find(p => p.type === entry.type);
|
|
const label = def?.label || entry.type;
|
|
return `<div class="search-result-item3d" onclick="jumpToSearchResult3d(${placedObjects.indexOf(entry)})">
|
|
<span>${def?.icon || "🔧"} ${label}</span>
|
|
<span style="color:#888;">${entry.mesh.position.x.toFixed(0)},${entry.mesh.position.z.toFixed(0)}</span>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
function jumpToSearchResult3d(index) {
|
|
const entry = placedObjects[index];
|
|
if (!entry) return;
|
|
// Kamera direkt neben das Objekt setzen, in Blickrichtung darauf
|
|
camera.position.set(entry.mesh.position.x + 3, entry.mesh.position.y + 3, entry.mesh.position.z + 3);
|
|
camYaw = Math.atan2(entry.mesh.position.x - camera.position.x, entry.mesh.position.z - camera.position.z);
|
|
camPitch = -0.5;
|
|
selectObject(entry);
|
|
}
|
|
|
|
function selectObject(entry) {
|
|
selectedEntry = entry;
|
|
selectedEntries = [entry]; // normale Auswahl ersetzt die Mehrfach-Auswahl
|
|
updateSelectionHighlight3d();
|
|
transformControls.attach(entry.mesh);
|
|
updatePortalLinkBox();
|
|
updateSignTextBox();
|
|
}
|
|
|
|
function updateSignTextBox() {
|
|
const box = document.getElementById("signTextBox");
|
|
if (!selectedEntry || selectedEntry.type !== "signpost") {
|
|
box.style.display = "none";
|
|
} else {
|
|
box.style.display = "block";
|
|
document.getElementById("signTextInput").value = selectedEntry.mesh.userData.labelText || "";
|
|
}
|
|
|
|
const gBox = document.getElementById("graffitiTextBox");
|
|
if (!selectedEntry || selectedEntry.type !== "graffiti") {
|
|
gBox.style.display = "none";
|
|
} else {
|
|
gBox.style.display = "block";
|
|
document.getElementById("graffitiTextInput").value = selectedEntry.mesh.userData.labelText || "";
|
|
document.getElementById("graffitiColorInput").value = selectedEntry.mesh.userData.graffitiColor || "#e84393";
|
|
}
|
|
}
|
|
|
|
// Malt den Text direkt AUF die Graffiti-Tafel (nicht als schwebendes
|
|
// Label darüber wie beim Wegweiser) - wirkt dadurch wie eine echte
|
|
// Wandbemalung statt eines Schilds. Eigene Funktion, weil sie sowohl
|
|
// beim Bearbeiten als auch beim Laden vom Server gebraucht wird
|
|
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);
|
|
// ein paar zufällige Sprühflecken für die "Wandbemalung"-Optik
|
|
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); // Textur soll unverfälscht durchkommen
|
|
mesh.material.needsUpdate = true;
|
|
}
|
|
|
|
function applyGraffitiText() {
|
|
if (!selectedEntry || selectedEntry.type !== "graffiti") return;
|
|
const text = document.getElementById("graffitiTextInput").value.trim();
|
|
const color = document.getElementById("graffitiColorInput").value;
|
|
selectedEntry.mesh.userData.labelText = text;
|
|
selectedEntry.mesh.userData.graffitiColor = color;
|
|
paintGraffitiTexture3d(selectedEntry.mesh, text, color);
|
|
}
|
|
|
|
function applySignText() {
|
|
if (!selectedEntry || selectedEntry.type !== "signpost") return;
|
|
const text = document.getElementById("signTextInput").value.trim();
|
|
selectedEntry.mesh.userData.labelText = text;
|
|
|
|
// vorhandenes Text-Sprite entfernen und neu bauen, damit die
|
|
// Änderung sofort sichtbar ist
|
|
const old = selectedEntry.mesh.children.find(c => c.userData.isSignLabel);
|
|
if (old) selectedEntry.mesh.remove(old);
|
|
if (text) {
|
|
const sprite = makeTextSprite3d(text);
|
|
sprite.position.y = 1.0;
|
|
sprite.userData.isSignLabel = true;
|
|
selectedEntry.mesh.add(sprite);
|
|
}
|
|
}
|
|
|
|
// Einfache Canvas-Text-Textur für Schild-Beschriftungen (kein
|
|
// externes Font-Asset nötig)
|
|
function makeTextSprite3d(text) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 256; canvas.height = 64;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.fillStyle = "rgba(20,20,20,0.85)";
|
|
ctx.fillRect(0, 0, 256, 64);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.font = "28px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(text, 128, 32);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
const material = new THREE.SpriteMaterial({ map: texture });
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.scale.set(1.6, 0.4, 1);
|
|
return sprite;
|
|
}
|
|
|
|
function updatePortalLinkBox() {
|
|
const box = document.getElementById("portalLinkBox");
|
|
if (!selectedEntry || (selectedEntry.type !== "portal" && selectedEntry.type !== "elevator")) {
|
|
box.style.display = "none";
|
|
return;
|
|
}
|
|
box.style.display = "block";
|
|
|
|
const select = document.getElementById("portalLinkSelect");
|
|
// nur mit demselben Typ verknüpfbar (Portal<->Portal, Fahrstuhl<->Fahrstuhl)
|
|
const otherPortals = placedObjects.filter(o => o.type === selectedEntry.type && o !== selectedEntry);
|
|
if (otherPortals.length === 0) {
|
|
select.innerHTML = `<option value="">- noch kein anderes ${selectedEntry.type === "elevator" ? "Fahrstuhl-Ziel" : "Portal"} vorhanden -</option>`;
|
|
return;
|
|
}
|
|
|
|
const currentLink = selectedEntry.mesh.userData.linkedTo;
|
|
select.innerHTML = `<option value="">- keine Verknüpfung -</option>` + otherPortals.map(o =>
|
|
`<option value="${o.mesh.userData.portalId}" ${o.mesh.userData.portalId === currentLink ? "selected" : ""}>
|
|
${selectedEntry.type === "elevator" ? "Fahrstuhl" : "Portal"} #${o.mesh.userData.portalId}
|
|
</option>`
|
|
).join("");
|
|
}
|
|
|
|
function linkSelectedPortal() {
|
|
if (!selectedEntry || (selectedEntry.type !== "portal" && selectedEntry.type !== "elevator")) return;
|
|
const targetId = Number(document.getElementById("portalLinkSelect").value) || null;
|
|
|
|
// alte Verknüpfung des Ziels aufheben, falls es vorher woanders hin verlinkt war
|
|
const oldTarget = placedObjects.find(o => o.type === selectedEntry.type && o.mesh.userData.portalId === selectedEntry.mesh.userData.linkedTo);
|
|
if (oldTarget) oldTarget.mesh.userData.linkedTo = null;
|
|
|
|
selectedEntry.mesh.userData.linkedTo = targetId;
|
|
|
|
// Verknüpfung ist immer gegenseitig (wie eure highway_links im Hauptspiel)
|
|
if (targetId) {
|
|
const target = placedObjects.find(o => o.type === selectedEntry.type && o.mesh.userData.portalId === targetId);
|
|
if (target) target.mesh.userData.linkedTo = selectedEntry.mesh.userData.portalId;
|
|
}
|
|
}
|
|
|
|
function deleteSelected(skipUndo) {
|
|
// Mehrfach-Auswahl: alle löschen statt nur das primäre Objekt
|
|
const toDelete = selectedEntries.length > 1 ? [...selectedEntries] : (selectedEntry ? [selectedEntry] : []);
|
|
if (toDelete.length === 0) return;
|
|
|
|
transformControls.detach();
|
|
toDelete.forEach(entry => {
|
|
scene.remove(entry.mesh);
|
|
const idx = placedObjects.indexOf(entry);
|
|
if (idx >= 0) placedObjects.splice(idx, 1);
|
|
if (!skipUndo) { undoStack.push({ action: "remove", entry }); redoStack.length = 0; }
|
|
});
|
|
|
|
selectedEntry = null;
|
|
selectedEntries = [];
|
|
updateSelectionHighlight3d();
|
|
updatePortalLinkBox();
|
|
updateObjectCount();
|
|
}
|
|
|
|
function undo() {
|
|
const last = undoStack.pop();
|
|
if (!last) return;
|
|
|
|
if (last.action === "add") {
|
|
// Rückgängig-Machen eines Platzierens = wieder entfernen
|
|
transformControls.detach();
|
|
scene.remove(last.entry.mesh);
|
|
const idx = placedObjects.indexOf(last.entry);
|
|
if (idx >= 0) placedObjects.splice(idx, 1);
|
|
redoStack.push({ action: "add", entry: last.entry }); // Wiederholen = nochmal hinzufügen
|
|
} else if (last.action === "remove") {
|
|
// Rückgängig-Machen eines Löschens = wieder hinzufügen
|
|
scene.add(last.entry.mesh);
|
|
placedObjects.push(last.entry);
|
|
redoStack.push({ action: "remove", entry: last.entry }); // Wiederholen = nochmal löschen
|
|
}
|
|
updateObjectCount();
|
|
}
|
|
|
|
function redo() {
|
|
const next = redoStack.pop();
|
|
if (!next) return;
|
|
|
|
if (next.action === "add") {
|
|
scene.add(next.entry.mesh);
|
|
placedObjects.push(next.entry);
|
|
undoStack.push({ action: "add", entry: next.entry });
|
|
} else if (next.action === "remove") {
|
|
transformControls.detach();
|
|
scene.remove(next.entry.mesh);
|
|
const idx = placedObjects.indexOf(next.entry);
|
|
if (idx >= 0) placedObjects.splice(idx, 1);
|
|
undoStack.push({ action: "remove", entry: next.entry });
|
|
}
|
|
updateObjectCount();
|
|
}
|
|
|
|
function duplicateSelected() {
|
|
const source = selectedEntries.length > 1 ? [...selectedEntries] : (selectedEntry ? [selectedEntry] : []);
|
|
if (source.length === 0) return;
|
|
|
|
const newEntries = source.map(entry => {
|
|
const point = new THREE.Vector3(entry.mesh.position.x + 1, 0, entry.mesh.position.z + 1);
|
|
const newEntry = placeObject(entry.type, point);
|
|
newEntry.mesh.rotation.copy(entry.mesh.rotation);
|
|
newEntry.mesh.scale.copy(entry.mesh.scale);
|
|
return newEntry;
|
|
});
|
|
|
|
if (newEntries.length > 1) {
|
|
selectedEntries = newEntries;
|
|
selectedEntry = newEntries[newEntries.length - 1];
|
|
updateSelectionHighlight3d();
|
|
transformControls.attach(selectedEntry.mesh);
|
|
updatePortalLinkBox();
|
|
updateSignTextBox();
|
|
} else {
|
|
selectObject(newEntries[0]);
|
|
}
|
|
}
|
|
|
|
// Werkzeug-Buttons: Modus umschalten
|
|
const TOOLS = [
|
|
{ mode: "translate", label: "Verschieben (1)" },
|
|
{ mode: "rotate", label: "Drehen (2)" },
|
|
{ mode: "scale", label: "Skalieren (3)" }
|
|
];
|
|
const toolButtonsEl = document.getElementById("toolButtons");
|
|
TOOLS.forEach((tool, i) => {
|
|
const btn = document.createElement("button");
|
|
btn.className = "tool-btn" + (i === 0 ? " active" : "");
|
|
btn.textContent = tool.label;
|
|
btn.dataset.mode = tool.mode;
|
|
btn.onclick = () => setToolMode(tool.mode);
|
|
toolButtonsEl.appendChild(btn);
|
|
});
|
|
|
|
function setToolMode(mode) {
|
|
transformControls.setMode(mode);
|
|
document.querySelectorAll("#toolButtons .tool-btn").forEach(b => {
|
|
b.classList.toggle("active", b.dataset.mode === mode);
|
|
});
|
|
}
|
|
|
|
// Tastenkürzel: 1/2/3 für die Werkzeuge (nicht G/R/S, da WASD jetzt
|
|
// die Kamera bewegt und "S" sonst kollidieren würde), Entf zum Löschen
|
|
document.addEventListener("keydown", e => {
|
|
if (e.key === "1") setToolMode("translate");
|
|
if (e.key === "2") setToolMode("rotate");
|
|
if (e.key === "3") setToolMode("scale");
|
|
if (e.key === "Delete" || e.key === "Backspace") deleteSelected();
|
|
if (e.ctrlKey && !e.shiftKey && e.key === "z") { e.preventDefault(); undo(); }
|
|
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "z")) { e.preventDefault(); redo(); }
|
|
if (e.ctrlKey && e.key === "d") { e.preventDefault(); duplicateSelected(); }
|
|
if (e.key === "Escape") {
|
|
selectedEntries = [];
|
|
selectedEntry = null;
|
|
updateSelectionHighlight3d();
|
|
transformControls.detach();
|
|
updatePortalLinkBox();
|
|
updateSignTextBox();
|
|
}
|
|
if (e.key === "t" && !e.ctrlKey) {
|
|
const typing = document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
|
|
if (!typing) toggleWalkMode();
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------
|
|
// EXPORT: Positionen/Rotationen/Skalierung als JSON, im selben Stil
|
|
// wie eure bestehende "objects"-Struktur in den 2D-Kartendaten
|
|
// -------------------------------------------------------------
|
|
// -------------------------------------------------------------
|
|
// OBJEKT-BAUKASTEN: eigene, kleine 3D-Szene, in der man mehrere
|
|
// Grundformen zu einem neuen, wiederverwendbaren Objekt kombiniert -
|
|
// wird danach als zusätzlicher Eintrag in die normale Palette
|
|
// aufgenommen und lässt sich beliebig oft platzieren
|
|
// -------------------------------------------------------------
|
|
const BUILDER_PRIMITIVES = [
|
|
{ type: "cube", icon: "⬛", geo: () => new THREE.BoxGeometry(1, 1, 1) },
|
|
{ type: "sphere", icon: "⚪", geo: () => new THREE.SphereGeometry(0.6, 14, 14) },
|
|
{ type: "cylinder", icon: "🥫", geo: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 14) },
|
|
{ type: "cone", icon: "🔺", geo: () => new THREE.ConeGeometry(0.6, 1.2, 14) }
|
|
];
|
|
|
|
let builderRenderer = null;
|
|
let builderScene = null;
|
|
let builderCamera = null;
|
|
let builderControls = null;
|
|
let builderGroup = null; // enthält alle Teile des gerade gebauten Objekts
|
|
let builderParts = []; // { mesh }
|
|
|
|
const CAR_BUILDER_TEMPLATES_3D = {
|
|
"Polizeiwagen": [
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.42,
|
|
"z": 0
|
|
},
|
|
"scale": {
|
|
"x": 1.0,
|
|
"y": 0.45,
|
|
"z": 2.1
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#f2f2f2"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.78,
|
|
"z": -0.15
|
|
},
|
|
"scale": {
|
|
"x": 0.85,
|
|
"y": 0.35,
|
|
"z": 1.1
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#f2f2f2"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0.51,
|
|
"y": 0.42,
|
|
"z": 0
|
|
},
|
|
"scale": {
|
|
"x": 0.06,
|
|
"y": 0.3,
|
|
"z": 2.0
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#1a3a8f"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": -0.51,
|
|
"y": 0.42,
|
|
"z": 0
|
|
},
|
|
"scale": {
|
|
"x": 0.06,
|
|
"y": 0.3,
|
|
"z": 2.0
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#1a3a8f"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 1.0,
|
|
"z": -0.15
|
|
},
|
|
"scale": {
|
|
"x": 0.5,
|
|
"y": 0.15,
|
|
"z": 0.3
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#222222"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": -0.13,
|
|
"y": 1.1,
|
|
"z": -0.15
|
|
},
|
|
"scale": {
|
|
"x": 0.2,
|
|
"y": 0.1,
|
|
"z": 0.25
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#2255ff"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0.13,
|
|
"y": 1.1,
|
|
"z": -0.15
|
|
},
|
|
"scale": {
|
|
"x": 0.2,
|
|
"y": 0.1,
|
|
"z": 0.25
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#e74c3c"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.5,
|
|
"y": 0.22,
|
|
"z": -0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.5,
|
|
"y": 0.22,
|
|
"z": -0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.5,
|
|
"y": 0.22,
|
|
"z": 0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.5,
|
|
"y": 0.22,
|
|
"z": 0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
}
|
|
],
|
|
"Krankenwagen": [
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.55,
|
|
"z": 0.1
|
|
},
|
|
"scale": {
|
|
"x": 1.15,
|
|
"y": 0.7,
|
|
"z": 2.6
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#ffffff"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.5,
|
|
"z": -1.15
|
|
},
|
|
"scale": {
|
|
"x": 1.0,
|
|
"y": 0.55,
|
|
"z": 0.6
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#e8e8e8"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.75,
|
|
"z": 0.5
|
|
},
|
|
"scale": {
|
|
"x": 0.5,
|
|
"y": 0.15,
|
|
"z": 0.05
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#e63946"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.75,
|
|
"z": 0.5
|
|
},
|
|
"scale": {
|
|
"x": 0.15,
|
|
"y": 0.15,
|
|
"z": 0.5
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 1.5707963267948966,
|
|
"z": 0
|
|
},
|
|
"color": "#e63946"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.95,
|
|
"z": -1.15
|
|
},
|
|
"scale": {
|
|
"x": 0.7,
|
|
"y": 0.12,
|
|
"z": 0.2
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#222222"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 1.03,
|
|
"z": -1.15
|
|
},
|
|
"scale": {
|
|
"x": 0.6,
|
|
"y": 0.08,
|
|
"z": 0.15
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#e74c3c"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.55,
|
|
"y": 0.22,
|
|
"z": -0.9
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.55,
|
|
"y": 0.22,
|
|
"z": -0.9
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.55,
|
|
"y": 0.22,
|
|
"z": 0.9
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.55,
|
|
"y": 0.22,
|
|
"z": 0.9
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
}
|
|
],
|
|
"Sportwagen": [
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.28,
|
|
"z": 0
|
|
},
|
|
"scale": {
|
|
"x": 1.0,
|
|
"y": 0.28,
|
|
"z": 2.2
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#c0392b"
|
|
},
|
|
{
|
|
"type": "cube",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.5,
|
|
"z": 0.1
|
|
},
|
|
"scale": {
|
|
"x": 0.7,
|
|
"y": 0.22,
|
|
"z": 0.9
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cone",
|
|
"position": {
|
|
"x": 0,
|
|
"y": 0.28,
|
|
"z": -1.15
|
|
},
|
|
"scale": {
|
|
"x": 0.5,
|
|
"y": 0.3,
|
|
"z": 0.5
|
|
},
|
|
"rotation": {
|
|
"x": 1.5707963267948966,
|
|
"y": 0,
|
|
"z": 0
|
|
},
|
|
"color": "#c0392b"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.52,
|
|
"y": 0.22,
|
|
"z": -0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.52,
|
|
"y": 0.22,
|
|
"z": -0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": -0.52,
|
|
"y": 0.22,
|
|
"z": 0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
},
|
|
{
|
|
"type": "cylinder",
|
|
"position": {
|
|
"x": 0.52,
|
|
"y": 0.22,
|
|
"z": 0.75
|
|
},
|
|
"scale": {
|
|
"x": 0.22,
|
|
"y": 0.15,
|
|
"z": 0.22
|
|
},
|
|
"rotation": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"z": 1.5707963267948966
|
|
},
|
|
"color": "#1a1a1a"
|
|
}
|
|
]
|
|
};
|
|
|
|
let builderSelected = null;
|
|
let customPaletteCounter = 1;
|
|
loadCustomPaletteFromStorage3d();
|
|
|
|
function initBuilderSceneOnce() {
|
|
if (builderRenderer) return; // nur einmal aufbauen, danach wiederverwenden
|
|
|
|
const canvasEl = document.getElementById("builderCanvas");
|
|
// Tatsächliche gerenderte Größe auslesen (CSS nutzt max-width/max-height,
|
|
// die feste Größe hängt also vom Bildschirm ab)
|
|
const w = canvasEl.clientWidth || 950;
|
|
const h = canvasEl.clientHeight || 720;
|
|
builderRenderer = new THREE.WebGLRenderer({ canvas: canvasEl, antialias: true });
|
|
builderRenderer.setSize(w, h);
|
|
|
|
builderScene = new THREE.Scene();
|
|
builderScene.background = new THREE.Color(0x2a2a35);
|
|
builderScene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
|
const light = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
light.position.set(5, 8, 5);
|
|
builderScene.add(light);
|
|
builderScene.add(new THREE.GridHelper(6, 12, 0x555577, 0x3a3a4a));
|
|
builderScene.add(new THREE.AxesHelper(2));
|
|
|
|
builderCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
|
|
builderCamera.position.set(4, 3, 4);
|
|
builderCamera.lookAt(0, 0, 0);
|
|
|
|
builderControls = new THREE.TransformControls(builderCamera, builderRenderer.domElement);
|
|
builderScene.add(builderControls);
|
|
|
|
// einfache Maus-Orbit-Steuerung nur innerhalb des kleinen Vorschau-Canvas
|
|
let dragging = false, lastX = 0, lastY = 0, yaw = 0.7, pitch = 0.6, dist = 6;
|
|
canvasEl.addEventListener("mousedown", e => { if (e.button === 0 && !builderControls.dragging) { dragging = true; lastX = e.clientX; lastY = e.clientY; } });
|
|
window.addEventListener("mouseup", () => { dragging = false; });
|
|
window.addEventListener("mousemove", e => {
|
|
if (!dragging) return;
|
|
yaw -= (e.clientX - lastX) * 0.008;
|
|
pitch = Math.max(0.15, Math.min(1.4, pitch - (e.clientY - lastY) * 0.008));
|
|
lastX = e.clientX; lastY = e.clientY;
|
|
builderCamera.position.set(Math.sin(yaw) * dist * Math.cos(pitch), dist * Math.sin(pitch), Math.cos(yaw) * dist * Math.cos(pitch));
|
|
builderCamera.lookAt(0, 0, 0);
|
|
});
|
|
builderControls.addEventListener("dragging-changed", e => { dragging = dragging && !e.value; });
|
|
|
|
// Mausrad zoomt rein/raus, indem der Kamera-Abstand vom Mittelpunkt
|
|
// verändert wird - vorher fest bei 6, kein Zoom möglich
|
|
canvasEl.addEventListener("wheel", e => {
|
|
e.preventDefault();
|
|
dist = Math.max(1.5, Math.min(25, dist + e.deltaY * 0.01));
|
|
builderCamera.position.set(Math.sin(yaw) * dist * Math.cos(pitch), dist * Math.sin(pitch), Math.cos(yaw) * dist * Math.cos(pitch));
|
|
builderCamera.lookAt(0, 0, 0);
|
|
}, { passive: false });
|
|
|
|
// Teil-Auswahl per Klick
|
|
const raycaster = new THREE.Raycaster();
|
|
canvasEl.addEventListener("click", e => {
|
|
if (dragging) return;
|
|
const rect = canvasEl.getBoundingClientRect();
|
|
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
raycaster.setFromCamera(ndc, builderCamera);
|
|
const hits = raycaster.intersectObjects(builderParts.map(p => p.mesh));
|
|
if (hits.length > 0) selectBuilderPart(builderParts.find(p => p.mesh === hits[0].object));
|
|
});
|
|
|
|
(function animateBuilder() {
|
|
requestAnimationFrame(animateBuilder);
|
|
if (document.getElementById("builderModal").style.display === "flex") {
|
|
builderRenderer.render(builderScene, builderCamera);
|
|
}
|
|
})();
|
|
|
|
// Palette der Grundformen im Baukasten
|
|
const paletteEl = document.getElementById("builderPartPalette");
|
|
BUILDER_PRIMITIVES.forEach(prim => {
|
|
const btn = document.createElement("div");
|
|
btn.className = "palette-btn";
|
|
btn.innerHTML = `<span class="icon">${prim.icon}</span>${prim.type}`;
|
|
btn.onclick = () => addBuilderPart(prim.type);
|
|
paletteEl.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
let isCarBuilderMode3d = false;
|
|
let editingPaletteType3d = null; // gesetzt beim Bearbeiten eines bestehenden Palette-Eintrags, sonst null
|
|
|
|
function openObjectBuilder() {
|
|
// Erst sichtbar machen, DANN die Szene initialisieren - initBuilderSceneOnce()
|
|
// liest die tatsächliche Canvas-Größe aus, die bei display:none noch 0 wäre
|
|
document.getElementById("builderModal").style.display = "flex";
|
|
initBuilderSceneOnce();
|
|
|
|
// Baukasten bei jedem Öffnen leeren, damit man frisch anfängt
|
|
builderParts.forEach(p => builderScene.remove(p.mesh));
|
|
builderParts = [];
|
|
builderSelected = null;
|
|
builderControls.detach();
|
|
document.getElementById("builderPartsList").innerHTML = "";
|
|
document.getElementById("builderName").value = "";
|
|
isCarBuilderMode3d = false;
|
|
editingPaletteType3d = null;
|
|
document.getElementById("builderFinishBtn").textContent = "✅ Fertig, zur Palette";
|
|
}
|
|
function closeObjectBuilder() {
|
|
document.getElementById("builderModal").style.display = "none";
|
|
}
|
|
|
|
// Öffnet denselben Baukasten wie "🔧 Baukasten öffnen", füllt aber
|
|
// direkt ein Fahrgestell + 4 Räder als Startpunkt vor - deutlich
|
|
// schneller, als jedes Auto komplett von Null aufzubauen
|
|
function openCarBuilder3d() {
|
|
openObjectBuilder();
|
|
isCarBuilderMode3d = true;
|
|
document.getElementById("builderName").value = "Mein Auto";
|
|
document.getElementById("builderFinishBtn").textContent = "🚗 Als Fahrzeug-Modell speichern";
|
|
|
|
// Fahrgestell (breiter, flacher Würfel)
|
|
addBuilderPart("cube");
|
|
const chassis = builderParts[builderParts.length - 1];
|
|
chassis.mesh.position.set(0, 0.5, 0);
|
|
chassis.mesh.scale.set(1.8, 0.6, 3.4);
|
|
|
|
// Fahrerkabine (kleinerer, höher sitzender Würfel)
|
|
addBuilderPart("cube");
|
|
const cabin = builderParts[builderParts.length - 1];
|
|
cabin.mesh.position.set(0, 1.0, -0.3);
|
|
cabin.mesh.scale.set(1.5, 0.5, 1.8);
|
|
|
|
// 4 Räder (Zylinder, quer gedreht)
|
|
const wheelPositions = [
|
|
[-1.0, 0.35, 1.2], [1.0, 0.35, 1.2],
|
|
[-1.0, 0.35, -1.2], [1.0, 0.35, -1.2]
|
|
];
|
|
wheelPositions.forEach(pos => {
|
|
addBuilderPart("cylinder");
|
|
const wheel = builderParts[builderParts.length - 1];
|
|
wheel.mesh.position.set(pos[0], pos[1], pos[2]);
|
|
wheel.mesh.scale.set(0.7, 0.3, 0.7);
|
|
wheel.mesh.rotation.z = Math.PI / 2;
|
|
});
|
|
|
|
builderControls.detach();
|
|
builderSelected = null;
|
|
renderBuilderPartsList();
|
|
}
|
|
|
|
function loadBuilderTemplate3d() {
|
|
const name = document.getElementById("builderTemplateSelect").value;
|
|
if (!name) return;
|
|
const template = CAR_BUILDER_TEMPLATES_3D[name];
|
|
if (!template) return;
|
|
|
|
// aktuellen Baukasten-Inhalt leeren, dann die Vorlage aufbauen
|
|
builderParts.forEach(p => builderScene.remove(p.mesh));
|
|
builderParts = [];
|
|
builderSelected = null;
|
|
builderControls.detach();
|
|
|
|
template.forEach(part => {
|
|
const prim = BUILDER_PRIMITIVES.find(bp => bp.type === part.type);
|
|
if (!prim) return;
|
|
const mesh = new THREE.Mesh(prim.geo(), new THREE.MeshLambertMaterial({ color: part.color }));
|
|
mesh.position.set(part.position.x, part.position.y, part.position.z);
|
|
mesh.scale.set(part.scale.x, part.scale.y, part.scale.z);
|
|
mesh.rotation.set(part.rotation.x, part.rotation.y, part.rotation.z);
|
|
builderScene.add(mesh);
|
|
builderParts.push({ mesh, type: part.type });
|
|
});
|
|
|
|
document.getElementById("builderName").value = name;
|
|
renderBuilderPartsList();
|
|
}
|
|
|
|
function addBuilderPart(type, color) {
|
|
const prim = BUILDER_PRIMITIVES.find(p => p.type === type);
|
|
const mesh = new THREE.Mesh(prim.geo(), new THREE.MeshLambertMaterial({ color: color ?? 0x5b8def }));
|
|
mesh.position.set(0, 0.5, 0);
|
|
builderScene.add(mesh);
|
|
const part = { mesh, type };
|
|
builderParts.push(part);
|
|
renderBuilderPartsList();
|
|
selectBuilderPart(part);
|
|
}
|
|
|
|
function selectBuilderPart(part) {
|
|
builderSelected = part;
|
|
builderControls.attach(part.mesh);
|
|
renderBuilderPartsList();
|
|
document.getElementById("builderPartColor").value = "#" + part.mesh.material.color.getHexString();
|
|
}
|
|
|
|
function setBuilderPartColor3d(hex) {
|
|
if (!builderSelected) return;
|
|
builderSelected.mesh.material.color.set(hex);
|
|
}
|
|
|
|
function renderBuilderPartsList() {
|
|
const list = document.getElementById("builderPartsList");
|
|
list.innerHTML = "";
|
|
builderParts.forEach((p, i) => {
|
|
const row = document.createElement("div");
|
|
row.className = "builder-part-row" + (p === builderSelected ? " active" : "");
|
|
row.innerHTML = `<span>${p.type} #${i + 1}</span><button onclick="event.stopPropagation(); removeBuilderPart(${i})">✕</button>`;
|
|
row.onclick = () => selectBuilderPart(p);
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function removeBuilderPart(index) {
|
|
const part = builderParts[index];
|
|
if (part === builderSelected) { builderControls.detach(); builderSelected = null; }
|
|
builderScene.remove(part.mesh);
|
|
builderParts.splice(index, 1);
|
|
renderBuilderPartsList();
|
|
}
|
|
|
|
function setBuilderMode(mode) {
|
|
builderControls.setMode(mode);
|
|
}
|
|
|
|
// Fertigstellen: alle Teile zu EINEM Group-Objekt zusammenfassen und
|
|
// als neuen, wiederverwendbaren Typ in die normale Palette aufnehmen
|
|
function finishObjectBuilder() {
|
|
if (builderParts.length === 0) {
|
|
alert("Erst mindestens ein Teil hinzufügen.");
|
|
return;
|
|
}
|
|
|
|
const blueprint = builderParts.map(p => ({
|
|
type: p.type,
|
|
position: p.mesh.position.clone(),
|
|
scale: p.mesh.scale.clone(),
|
|
rotation: p.mesh.rotation.clone(),
|
|
color: "#" + p.mesh.material.color.getHexString()
|
|
}));
|
|
|
|
if (isCarBuilderMode3d) {
|
|
saveCarModel3d(blueprint);
|
|
return;
|
|
}
|
|
|
|
const name = document.getElementById("builderName").value.trim() || `Eigenes Objekt ${customPaletteCounter}`;
|
|
|
|
if (editingPaletteType3d) {
|
|
// Bestehenden Eintrag aktualisieren statt einen neuen anzulegen
|
|
const entry = PALETTE.find(p => p.type === editingPaletteType3d);
|
|
if (entry) {
|
|
entry.label = name;
|
|
entry.blueprint = blueprint;
|
|
}
|
|
editingPaletteType3d = null;
|
|
rebuildPaletteUI();
|
|
saveCustomPaletteToStorage3d();
|
|
closeObjectBuilder();
|
|
alert("Form aktualisiert. Bereits platzierte Exemplare in der aktuellen Sitzung übernehmen das nicht automatisch - lade die Welt neu (\"Gespeicherte Objekte laden\"), nachdem du auf dem Server gespeichert hast, um sie zu aktualisieren.");
|
|
return;
|
|
}
|
|
|
|
const customType = "custom_" + (customPaletteCounter++);
|
|
|
|
PALETTE.push({
|
|
type: customType,
|
|
icon: "🔧",
|
|
label: name,
|
|
color: 0x5b8def,
|
|
baseY: 0,
|
|
isCustom: true,
|
|
blueprint,
|
|
geo: null // wird nicht genutzt, custom Typen bauen ihr Mesh anders (siehe placeObject)
|
|
});
|
|
|
|
rebuildPaletteUI();
|
|
saveCustomPaletteToStorage3d();
|
|
closeObjectBuilder();
|
|
}
|
|
|
|
// Speichert den Bauplan als Fahrzeug-Modell, verknüpft mit einem echten
|
|
// car_configs.model-Namen - der 3D-Client nutzt das für ALLE Fahrzeuge
|
|
// dieses Modells anstelle des Platzhalter-Trucks
|
|
async function saveCarModel3d(blueprint) {
|
|
const model = prompt(
|
|
"Modell-Name eingeben - muss EXAKT mit dem \"model\"-Feld in eurer " +
|
|
"car_configs-Tabelle übereinstimmen (siehe admin_cars.html), z.B. \"sedan1\":"
|
|
);
|
|
if (!model || !model.trim()) return;
|
|
const modelTrimmed = model.trim();
|
|
|
|
if (!editorToken) {
|
|
alert("Zum Speichern musst du eingeloggt sein (mit Karten-Bearbeitungsrecht).");
|
|
return;
|
|
}
|
|
|
|
// Vorab prüfen, ob es für diesen Namen überhaupt eine Fahrphysik gibt -
|
|
// das Aussehen (hier) und die Physik (car_configs/admin_cars.html) sind
|
|
// ZWEI GETRENNTE Systeme. Ohne passenden car_configs-Eintrag lässt sich
|
|
// das Auto zwar ansehen und einsteigen, bewegt sich aber nie (der
|
|
// Server überspringt die Physik-Berechnung dafür komplett)
|
|
try {
|
|
const configRes = await fetch("/api/car_configs");
|
|
const configData = await configRes.json();
|
|
if (configData.ok && !configData.configs[modelTrimmed]) {
|
|
const proceed = confirm(
|
|
`⚠️ Für das Modell "${modelTrimmed}" gibt es noch KEINEN Eintrag in car_configs ` +
|
|
`(admin_cars.html). Ohne den bleibt das Auto zwar sichtbar, fährt aber NIE los, ` +
|
|
`egal wie viel Gas gegeben wird.\n\n` +
|
|
`Trotzdem so speichern? (Physik-Eintrag musst du dann noch in admin_cars.html nachtragen)`
|
|
);
|
|
if (!proceed) return;
|
|
}
|
|
} catch {
|
|
// Prüfung fehlgeschlagen (z.B. offline) - Speichern trotzdem erlauben,
|
|
// nur ohne die Vorab-Warnung
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/admin/car_models_3d", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + editorToken },
|
|
body: JSON.stringify({ model: modelTrimmed, blueprint })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.ok) {
|
|
alert(`✅ Fahrzeug-Modell "${modelTrimmed}" gespeichert. Wird beim nächsten Betreten der Welt im Spiel verwendet.`);
|
|
closeObjectBuilder();
|
|
} else {
|
|
alert("❌ Fehler: " + (data.error || "unbekannt"));
|
|
}
|
|
} catch {
|
|
alert("❌ Anfrage fehlgeschlagen.");
|
|
}
|
|
}
|
|
|
|
// Baut die komplette Palette-Leiste neu auf (nötig, weil jetzt auch
|
|
// selbst gebaute Objekte dazukommen können)
|
|
function rebuildPaletteUI() {
|
|
const paletteEl = document.getElementById("palette");
|
|
paletteEl.innerHTML = "";
|
|
PALETTE.forEach(item => {
|
|
const row = document.createElement("div");
|
|
row.style.cssText = "display:flex; align-items:center; gap:2px;";
|
|
|
|
const btn = document.createElement("div");
|
|
btn.className = "palette-btn" + (selectedPaletteType === item.type ? " active" : "");
|
|
btn.style.flex = "1";
|
|
btn.innerHTML = `<span class="icon">${item.icon}</span>${item.label}`;
|
|
btn.onclick = () => {
|
|
selectedPaletteType = selectedPaletteType === item.type ? null : item.type;
|
|
rebuildPaletteUI();
|
|
};
|
|
row.appendChild(btn);
|
|
|
|
// Selbstgebaute Objekte lassen sich nachträglich im Baukasten
|
|
// bearbeiten (Form ändern), nicht nur einzelne platzierte
|
|
// Exemplare verschieben/drehen
|
|
if (item.isCustom) {
|
|
const editBtn = document.createElement("button");
|
|
editBtn.textContent = "✏️";
|
|
editBtn.title = "Form im Baukasten bearbeiten";
|
|
editBtn.style.cssText = "background:#444; border:none; color:#eee; border-radius:4px; cursor:pointer; padding:4px 6px; font-size:12px;";
|
|
editBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
openObjectBuilderForEdit3d(item);
|
|
};
|
|
row.appendChild(editBtn);
|
|
}
|
|
|
|
paletteEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Öffnet den Baukasten mit dem BESTEHENDEN Bauplan eines Palette-Eintrags
|
|
// vorausgefüllt - beim Speichern wird derselbe Typ aktualisiert statt ein
|
|
// neuer angelegt (bereits PLATZIERTE Exemplare in der aktuellen Sitzung
|
|
// übernehmen die neue Form nicht automatisch - dafür einfach die Welt
|
|
// über "Gespeicherte Objekte laden" neu laden, nachdem gespeichert wurde)
|
|
function openObjectBuilderForEdit3d(item) {
|
|
document.getElementById("builderModal").style.display = "flex";
|
|
initBuilderSceneOnce();
|
|
|
|
builderParts.forEach(p => builderScene.remove(p.mesh));
|
|
builderParts = [];
|
|
builderSelected = null;
|
|
builderControls.detach();
|
|
|
|
item.blueprint.forEach(part => {
|
|
const prim = BUILDER_PRIMITIVES.find(bp => bp.type === part.type);
|
|
if (!prim) return;
|
|
const mesh = new THREE.Mesh(prim.geo(), new THREE.MeshLambertMaterial({ color: part.color || 0x5b8def }));
|
|
mesh.position.copy(part.position);
|
|
mesh.scale.copy(part.scale);
|
|
mesh.rotation.copy(part.rotation);
|
|
builderScene.add(mesh);
|
|
builderParts.push({ mesh, type: part.type });
|
|
});
|
|
renderBuilderPartsList();
|
|
|
|
document.getElementById("builderName").value = item.label;
|
|
document.getElementById("builderFinishBtn").textContent = "✅ Änderungen speichern";
|
|
isCarBuilderMode3d = false;
|
|
editingPaletteType3d = item.type; // steuert finishObjectBuilder(), ob neu angelegt oder aktualisiert wird
|
|
}
|
|
|
|
function exportJson() {
|
|
const data = placedObjects.map(o => ({
|
|
type: o.type,
|
|
x: Math.round(o.mesh.position.x * 100) / 100,
|
|
y: Math.round(o.mesh.position.y * 100) / 100,
|
|
z: Math.round(o.mesh.position.z * 100) / 100,
|
|
rotationY: Math.round(o.mesh.rotation.y * 100) / 100,
|
|
scale: Math.round(o.mesh.scale.x * 100) / 100,
|
|
...(o.type === "portal" ? { portalId: o.mesh.userData.portalId, linkedTo: o.mesh.userData.linkedTo } : {})
|
|
}));
|
|
document.getElementById("jsonOutput").value = JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
function importJson() {
|
|
const raw = document.getElementById("jsonOutput").value.trim();
|
|
if (!raw) return;
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(raw);
|
|
} catch {
|
|
alert("Ungültiges JSON.");
|
|
return;
|
|
}
|
|
|
|
// aktuelle Objekte erst entfernen
|
|
transformControls.detach();
|
|
placedObjects.forEach(o => scene.remove(o.mesh));
|
|
placedObjects.length = 0;
|
|
selectedEntry = null;
|
|
|
|
data.forEach(item => {
|
|
// Unbekannten custom_-Typ (z.B. aus einem importierten Gebäude-Bauplan)
|
|
// erst in die Palette aufnehmen, damit er danach auch mehrfach
|
|
// platzierbar ist und beim Speichern seinen Bauplan mitbekommt
|
|
if (item.type.startsWith("custom_") && !PALETTE.find(p => p.type === item.type) && item.blueprint) {
|
|
PALETTE.push({
|
|
type: item.type,
|
|
icon: "🔧",
|
|
label: item.label || item.type,
|
|
color: 0x5b8def,
|
|
baseY: 0,
|
|
isCustom: true,
|
|
blueprint: item.blueprint.map(bp => ({
|
|
type: bp.type,
|
|
position: new THREE.Vector3(bp.position.x, bp.position.y, bp.position.z),
|
|
scale: new THREE.Vector3(bp.scale.x, bp.scale.y, bp.scale.z),
|
|
rotation: new THREE.Euler(bp.rotation._x ?? bp.rotation.x, bp.rotation._y ?? bp.rotation.y, bp.rotation._z ?? bp.rotation.z),
|
|
color: bp.color
|
|
}))
|
|
});
|
|
rebuildPaletteUI();
|
|
saveCustomPaletteToStorage3d();
|
|
}
|
|
|
|
if (!PALETTE.find(p => p.type === item.type)) return; // unbekannter Typ überspringen
|
|
const entry = placeObject(item.type, new THREE.Vector3(item.x, item.y, item.z), true);
|
|
entry.mesh.position.y = item.y ?? entry.mesh.position.y;
|
|
entry.mesh.rotation.y = item.rotationY || 0;
|
|
if (item.scale) entry.mesh.scale.set(item.scale, item.scale, item.scale);
|
|
if (item.type === "portal" && item.portalId) {
|
|
entry.mesh.userData.portalId = item.portalId;
|
|
entry.mesh.userData.linkedTo = item.linkedTo || null;
|
|
nextPortalId = Math.max(nextPortalId, item.portalId + 1);
|
|
}
|
|
});
|
|
updateObjectCount();
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// RENDER-LOOP
|
|
// -------------------------------------------------------------
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
updateCamera();
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
</script>
|
|
</body>
|
|
</html> |