diff --git a/node/server/public/app.js b/node/server/public/app.js index 24690ca..4432cfa 100644 --- a/node/server/public/app.js +++ b/node/server/public/app.js @@ -50,14 +50,17 @@ function onResize() { let renderW, renderH, styleW, styleH; if (winW / winH > aspect) { + // Querformat (breit) → CONTAIN renderH = winH; renderW = winH * aspect; styleW = `${renderW}px`; styleH = `${renderH}px`; } else { + // Hochformat oder quadratisch → COVER renderW = winW; renderH = winW / aspect; if (renderH < winH) { + // Ist nach Covern immer noch zu klein? Dann auf volle Höhe und rechts/links abschneiden renderH = winH; renderW = winH * aspect; } @@ -117,7 +120,8 @@ draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); const gltfLoader = new GLTFLoader(); gltfLoader.setDRACOLoader(draco); -// ---- SpinnerController ---- + +// Virtuelles Interdimensionales Geisterteleportationsgerät class SpinnerController { constructor(scene) { this.scene = scene; @@ -131,15 +135,16 @@ class SpinnerController { this.clock = new THREE.Clock(); this.ws = null; this.reconnectDelay = 2000; - this.connected = false; + this.connected = false; // Verbindungsstatus + + // Werte für sanften Übergang this.transition = { emission: 3.0, targetEmission: 3.0, bobMult: 0.5, targetBobMult: 0.5, rotSpeed: 1.2, targetRotSpeed: 1.2, lightIntensity: 5, targetLightIntensity: 5, - lerpSpeed: 1/3 + lerpSpeed: 1/3 // 1/x Sekunden bis Ziel (hier: ca. 3s) }; - this.activeSpirits = []; this.init(); } @@ -186,8 +191,10 @@ class SpinnerController { return obj; } + // --- Werte sanft angleichen --- smoothTransition(dt) { let T = this.transition; + // Zielwerte setzen if (this.connected) { T.targetEmission = 3.0; T.targetBobMult = 0.5; @@ -199,7 +206,8 @@ class SpinnerController { T.targetRotSpeed = 0.08; T.targetLightIntensity = 0; } - const s = T.lerpSpeed * dt; + // Lerp (sanft angleichen) + const s = T.lerpSpeed * dt; // kleiner dt → smooth T.emission += (T.targetEmission - T.emission) * s; T.bobMult += (T.targetBobMult - T.bobMult) * s; T.rotSpeed += (T.targetRotSpeed - T.rotSpeed) * s; @@ -213,12 +221,14 @@ class SpinnerController { const bob = Math.sin(t * 1.2) * T.bobMult; const baseY = this.baseY + bob; + // Spinner if (this.spinnerRed && this.spinnerBlue) { this.spinnerRed.position.y = baseY + 0.8; this.spinnerBlue.position.y = baseY; this.spinnerRed.rotation.y -= T.rotSpeed * dt; this.spinnerBlue.rotation.y += T.rotSpeed * dt; + // Emission auf beide Spinner anwenden this.spinnerRed.traverse(c => { if (c.isMesh && c.material && c.material.isMeshStandardMaterial) c.material.emissiveIntensity = T.emission; @@ -229,6 +239,7 @@ class SpinnerController { }); } + // Rotierende Lichter (jetzt mit smooth intensity und Speed) for (let i = 0; i < this.lights.length; i++) { const ang = t * 0.8 + i * 2 * Math.PI / 3; this.lights[i].position.set( @@ -260,9 +271,11 @@ class SpinnerController { const msg = JSON.parse(event.data); if (msg.type === 'spirit') { if (typeof msg.timeSinceSpawnMs === "number" && msg.timeSinceSpawnMs > 0) { - await this.spawnSpiritWithOffset(msg.data, msg.timeSinceSpawnMs, msg.spiritIntervalMs); + // Initiale Verbindung: Mit Offset + spawnSpiritWithOffset(msg.data, msg.timeSinceSpawnMs, msg.spiritIntervalMs); } else { - await this.spawnSpirit(msg.data); + // Normales Timer-Event + spawnSpirit(msg.data); } } }); @@ -277,40 +290,9 @@ class SpinnerController { this.ws.close(); }); } - - async spawnSpirit(spiritData) { - let spawnPos = { - x: 0, - y: this.spinnerRed ? this.spinnerRed.position.y - 1.5 : 15, - z: 0.88 - }; - const modelUrl = spiritData['Model URL'] || spiritData.modelUrl; - const { scene: gltfScene } = await gltfLoader.loadAsync(modelUrl); - const spirit = new Spirit(this.scene, gltfScene, spiritData, spawnPos); - spirit.clock.start(); - this.activeSpirits.push(spirit); - } - - async spawnSpiritWithOffset(spiritData, timeSinceSpawnMs = 0, spiritIntervalMs = 20000) { - let startY = this.spinnerRed ? this.spinnerRed.position.y - 1.5 : 15; - let offset = (typeof timeSinceSpawnMs === 'number' && timeSinceSpawnMs > 0) ? timeSinceSpawnMs / 1000 : 0; - let lifeTime = (spiritIntervalMs ? spiritIntervalMs : 20000) / 1000; - const despawnSpeed = 0.8; - let spawnPos = { x: 0, y: startY - (despawnSpeed * offset), z: 0.88 }; - - const modelUrl = spiritData['Model URL'] || spiritData.modelUrl; - const { scene: gltfScene } = await gltfLoader.loadAsync(modelUrl); - - const spirit = new Spirit(this.scene, gltfScene, spiritData, spawnPos); - spirit.clock.start(); - if (offset > 0 && offset < lifeTime) { - spirit.clock.elapsedTime = offset; - } - spirit.lifeTime = lifeTime; - this.activeSpirits.push(spirit); - } } + // ---- Spirit-Klasse ---- class Spirit { constructor(scene, gltfScene, info, spawnPosition) { @@ -318,10 +300,10 @@ class Spirit { this.grp = new THREE.Group(); this.gltf = gltfScene; this.info = info || {}; - this.spawnY = spawnPosition.y; + this.spawnY = spawnPosition.y; // immer gleiche Start-Y this.clock = new THREE.Clock(); this.isFading = true; - this.lifeTime = 20; + this.lifeTime = 20; // Sekunden this.spiritMeshes = []; this.grp.add(this.gltf); this.gltf.rotation.x = -Math.PI; @@ -349,8 +331,10 @@ class Spirit { const t = this.clock.getElapsedTime(); const despawnSpeed = 0.8; + // **NEU: Y-Position dynamisch berechnen!** this.grp.position.y = this.spawnY - despawnSpeed * t; + // Fading if (this.spiritMeshes && this.isFading) { for (const mesh of this.spiritMeshes) { if (t < 0.5) { @@ -394,6 +378,7 @@ class Spirit { } _setupPicking() { + // Hier ein einfacher Ansatz: Mesh mit Info-Objekt merken! this.gltf.traverse(mesh => { if (mesh.isMesh) { mesh.userData._spiritInfo = this.info; @@ -405,9 +390,11 @@ class Spirit { // ---- Szene initialisieren ---- let spinnerController; let landscape, torigate, shadowTree; +const activeSpirits = []; const clock = new THREE.Clock(); (async () => { + // Lade statische Models landscape = await loadGLB('assets/models/landscape.glb', [0, 0, 0], [90, 0, 0], { receiveShadow: true, castShadow: true }); torigate = await loadGLB('assets/models/tori.glb', [0, 6.59, 0.375], [90, 0, 0], { receiveShadow: true, castShadow: true }); shadowTree = await loadGLB('assets/models/tree_low.glb', [0, 0, 0], [90, 0, 0], { receiveShadow: false, castShadow: true, shadowOnly: true }); @@ -442,23 +429,131 @@ async function loadGLB(path, pos, rotDeg, { receiveShadow = false, castShadow = return obj; } -// ---- Overlay, Picking, Render-Loop ---- +// ---- Spirit spawnen ---- +async function spawnSpirit(spiritData) { + let spawnPos = { x: 0, y: spinnerController && spinnerController.spinnerRed ? spinnerController.spinnerRed.position.y - 1.5 : 15, z: 0.88 }; + const modelUrl = spiritData['Model URL'] || spiritData.modelUrl; // Fallback! + const { scene: gltfScene } = await gltfLoader.loadAsync(modelUrl); + const spirit = new Spirit(scene, gltfScene, spiritData, spawnPos); + spirit.clock.start(); // neu! + activeSpirits.push(spirit); +} + +async function spawnSpiritWithOffset(spiritData, timeSinceSpawnMs = 0, spiritIntervalMs = 20000) { + let spawnPos = { x: 0, y: spinnerController && spinnerController.spinnerRed ? spinnerController.spinnerRed.position.y - 1.5 : 15, z: 0.88 }; + const modelUrl = spiritData['Model URL'] || spiritData.modelUrl; + const { scene: gltfScene } = await gltfLoader.loadAsync(modelUrl); + + let offset = (typeof timeSinceSpawnMs === 'number' && timeSinceSpawnMs > 0) ? timeSinceSpawnMs / 1000 : 0; + let lifeTime = (spiritIntervalMs ? spiritIntervalMs : 20000) / 1000; + + const spirit = new Spirit(scene, gltfScene, spiritData, spawnPos); + spirit.clock.start(); + if (offset > 0 && offset < lifeTime) { + spirit.clock.elapsedTime = offset; + } + spirit.lifeTime = lifeTime; + activeSpirits.push(spirit); +} + +// ---- Overlay-Logik ---- let lastOverlaySpiritData = null; -function showSpiritOverlay(spirit) { /* ...deine Overlay-Logik... */ } -function closeSpiritOverlay() { /* ...deine Close-Logik... */ } +// Overlay zentriert in der Mitte mit Schließen-X +function showSpiritOverlay(spirit) { + // Entferne evtl. vorherige Overlay-Elemente + document.getElementById('spirit-info-backdrop')?.remove(); + // Erzeuge einen semi-transparenten Backdrop + const backdrop = document.createElement('div'); + backdrop.id = 'spirit-info-backdrop'; + backdrop.style = ` + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 9998; + `; + document.body.appendChild(backdrop); + + let el = document.getElementById('spirit-info'); + if (!el) { + el = document.createElement('div'); + el.id = 'spirit-info'; + el.style = ` + position: fixed; + left: 50%; top: 50%; + transform: translate(-50%,-50%); + color: white; + background: rgba(0,0,0,0.94); + padding: 28px 36px 24px 36px; + border-radius: 18px; + font-family: 'Segoe UI', sans-serif; + z-index: 9999; + max-width: 560px; + min-width: 320px; + box-shadow: 0 12px 64px #000a; + text-align: left; + `; + document.body.appendChild(el); + } + el.innerHTML = ` + + ${spirit['Image URL'] ? `Spirit Image` : ''} +

${spirit.Name || 'Spirit'}

+ ${spirit.Kategorie || ''}

+ Mythos: ${spirit["Mythos/Legende"] || ''}

+ Rolle: ${spirit["Funktion/Rolle"] || ''}
+ Charakter: ${spirit.Charakter || ''}

+ ${spirit.Herkunft ? '' + spirit.Herkunft + '' : ''} + `; + el.style.display = "block"; + lastOverlaySpiritData = spirit; + + // Close-Button Event (X + Fläche) + el.querySelector("#spirit-overlay-close").onclick = () => closeSpiritOverlay(); + + // Schließen beim Klick auf den Backdrop + backdrop.onclick = (e) => { + if (e.target === backdrop) closeSpiritOverlay(); + }; + // Schließen bei Escape bleibt: + document.onkeydown = (e) => { + if (e.key === 'Escape') closeSpiritOverlay(); + }; +} + +// Ausgelagerte Close-Logik (zentral, für mehrfaches Schließen) +function closeSpiritOverlay() { + document.getElementById('spirit-info')?.remove(); + document.getElementById('spirit-info-backdrop')?.remove(); + document.onkeydown = null; +} + +// Mouse-Picking (zentral) const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); function onClick(e) { + // Normierte Koordinaten im WebGL-Fenster: const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); + // Sammle alle Meshes aus allen aktiven Spirits let allMeshes = []; - for (const spirit of spinnerController.activeSpirits) { + for (const spirit of activeSpirits) { spirit.gltf.traverse(mesh => { if (mesh.isMesh) allMeshes.push(mesh); }); @@ -471,15 +566,23 @@ function onClick(e) { } } } + +// Fügt das Event hinzu: renderer.domElement.addEventListener('pointerdown', onClick); +// Kein automatisches Update mehr! Nicht von spawnSpirit aufrufen! +// (aber Option: „verstecke Overlay“ falls Spirit verschwindet, kann man so machen...) + +// ---- Render-Loop ---- function animate() { const dt = clock.getDelta(), t = clock.getElapsedTime(); + // Spinner-Animation & Netzwerk if (spinnerController) spinnerController.animate(dt, t); - for (let i = spinnerController.activeSpirits.length - 1; i >= 0; i--) { - if (!spinnerController.activeSpirits[i].update(dt)) { - spinnerController.activeSpirits.splice(i, 1); + // Update & remove expired spirits: + for (let i = activeSpirits.length - 1; i >= 0; i--) { + if (!activeSpirits[i].update(dt)) { + activeSpirits.splice(i, 1); } }