Files
2026-08-22 08:40:29 +02:00

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>