auto-git:
[add] package.json [add] server/public/app.js [add] server/public/index.html [add] server/server.js [add] server/spirits/spirit_list.json [unlink] node/package-lock.json [unlink] node/package.json [unlink] node/server/public/app.js [unlink] node/server/public/index.html [unlink] node/server/server.js [unlink] node/server/spirits/spirit_list.json
This commit is contained in:
623
server/public/app.js
Normal file
623
server/public/app.js
Normal file
@@ -0,0 +1,623 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'GLTFLoader';
|
||||
import { DRACOLoader } from 'DRACOLoader';
|
||||
import { EffectComposer } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { ShaderPass } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/ShaderPass.js';
|
||||
import { UnrealBloomPass } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
import { GammaCorrectionShader } from 'https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/shaders/GammaCorrectionShader.js';
|
||||
import { VignetteShader } from "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/shaders/VignetteShader.js";
|
||||
|
||||
// -------- Adaptive Settings --------
|
||||
function isMobileDevice() {
|
||||
return /Mobi|Android|iPhone|iPad|iPod|Opera Mini|IEMobile|BlackBerry|webOS/i.test(navigator.userAgent);
|
||||
}
|
||||
const IS_MOBILE = isMobileDevice();
|
||||
const LOW_PERFORMANCE = IS_MOBILE; // ... später aus FPS ableitbar
|
||||
|
||||
let targetFps = LOW_PERFORMANCE ? 30 : 60;
|
||||
let lastAnim = 0;
|
||||
|
||||
const container = document.getElementById('viewer');
|
||||
|
||||
// ---- Basis Three.js Szene ----
|
||||
const scene = new THREE.Scene();
|
||||
const ASPECT = 3 / 2, SCALE = 15;
|
||||
const hw = SCALE / 2, hh = (SCALE / ASPECT) / 2;
|
||||
const camera = new THREE.OrthographicCamera(-hw, hw, hh, -hh, 0.1, 1000);
|
||||
camera.position.set(0, -14.424, 20);
|
||||
camera.rotation.set(THREE.MathUtils.degToRad(55), 0, 0);
|
||||
|
||||
// --- Renderer ---
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: !IS_MOBILE });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = IS_MOBILE ? THREE.BasicShadowMap : THREE.PCFSoftShadowMap;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, IS_MOBILE ? 1 : 2);
|
||||
renderer.setPixelRatio(dpr);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// ---- Foliage-Shader ----
|
||||
const foliageTexture = new THREE.TextureLoader().load('assets/images/sprites/foliage.png');
|
||||
foliageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
const FoliageOverlayShader = {
|
||||
uniforms: { 'tDiffuse': { value: null }, 'tFoliage': { value: foliageTexture }, 'opacity': { value: 1.0 } },
|
||||
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||||
fragmentShader: `uniform sampler2D tDiffuse; uniform sampler2D tFoliage; uniform float opacity; varying vec2 vUv;
|
||||
void main() { vec4 base = texture2D(tDiffuse, vUv); vec4 foliage = texture2D(tFoliage, vUv); gl_FragColor = mix(base, vec4(foliage.rgb, base.a), foliage.a * opacity); }`
|
||||
};
|
||||
const foliageOverlayPass = new ShaderPass(FoliageOverlayShader);
|
||||
|
||||
// --- Post-Processing ---
|
||||
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
composer.addPass(foliageOverlayPass);
|
||||
composer.addPass(new UnrealBloomPass(new THREE.Vector2(container.clientWidth, container.clientHeight), 0.8, 0.2, 0.4));
|
||||
const vignettePass = new ShaderPass(VignetteShader);
|
||||
vignettePass.uniforms['offset'].value = 0.3;
|
||||
vignettePass.uniforms['darkness'].value = 1.35;
|
||||
composer.addPass(vignettePass);
|
||||
composer.addPass(new ShaderPass(GammaCorrectionShader));
|
||||
|
||||
// ---- Resize Handler ----
|
||||
function onResize() {
|
||||
const winW = window.innerWidth, winH = window.innerHeight;
|
||||
const aspect = 3/2;
|
||||
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;
|
||||
}
|
||||
styleW = `${renderW}px`;
|
||||
styleH = `${renderH}px`;
|
||||
}
|
||||
|
||||
renderer.setSize(renderW, renderH, false);
|
||||
composer.setSize(renderW, renderH);
|
||||
|
||||
renderer.domElement.style.width = styleW;
|
||||
renderer.domElement.style.height = styleH;
|
||||
renderer.domElement.style.left = '50%';
|
||||
renderer.domElement.style.top = '50%';
|
||||
renderer.domElement.style.transform = 'translate(-50%, -50%)';
|
||||
|
||||
renderer.setScissorTest(false);
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, IS_MOBILE ? 1 : 2);
|
||||
renderer.setPixelRatio(dpr);
|
||||
composer.setPixelRatio(dpr);
|
||||
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
||||
// ---- Environment ----
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
const pmremGen = new THREE.PMREMGenerator(renderer);
|
||||
texLoader.load('assets/images/hdri/environment.jpg', tex => {
|
||||
const envRT = pmremGen.fromEquirectangular(tex).texture;
|
||||
scene.environment = envRT;
|
||||
scene.background = envRT;
|
||||
tex.dispose();
|
||||
pmremGen.dispose();
|
||||
});
|
||||
|
||||
// ---- Licht, Shadow-Only-Material, Loader ----
|
||||
const sun = new THREE.DirectionalLight(0xFFA230, 2);
|
||||
sun.position.set(21, -25, 30);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.width = 2048;
|
||||
sun.shadow.mapSize.height = 2048;
|
||||
sun.shadow.camera.near = 1;
|
||||
sun.shadow.camera.far = 100;
|
||||
sun.shadow.camera.left = -30;
|
||||
sun.shadow.camera.right = 30;
|
||||
sun.shadow.camera.top = 30;
|
||||
sun.shadow.camera.bottom = -30;
|
||||
scene.add(sun);
|
||||
|
||||
const shadowOnlyMaterial = new THREE.MeshBasicMaterial({ color: 0x000000, opacity: 0.01, transparent: true, depthWrite: false });
|
||||
|
||||
const draco = new DRACOLoader();
|
||||
draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');
|
||||
const gltfLoader = new GLTFLoader();
|
||||
gltfLoader.setDRACOLoader(draco);
|
||||
|
||||
|
||||
|
||||
// Virtuelles Interdimensionales Geisterteleportationsgerät
|
||||
class SpinnerController {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.spinnerRed = null;
|
||||
this.spinnerBlue = null;
|
||||
this.lights = [];
|
||||
this.counterLights = [];
|
||||
this.center = new THREE.Vector3(0, 16.55, 1.5);
|
||||
this.LIGHT_RADIUS = 1;
|
||||
this.mobileLight = null;
|
||||
this.baseY = 16.55;
|
||||
this.clock = new THREE.Clock();
|
||||
this.ws = null;
|
||||
this.reconnectDelay = 2000;
|
||||
this.connected = false;
|
||||
|
||||
// Sanfte Übergänge
|
||||
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
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.spinnerRed = await this.loadSpinner('assets/models/spinner_red.glb', [0, 16.55, 0.88], [90, 0, 0], "#ff3333", 0.2);
|
||||
this.spinnerBlue = await this.loadSpinner('assets/models/spinner_blue.glb', [0, 16.55, 0.88], [90, 0, 0], "#3380ff", 0.2);
|
||||
|
||||
if (IS_MOBILE) {
|
||||
// --- Nur ein zentrales Licht auf Mobile
|
||||
this.mobileLight = new THREE.PointLight(0xFFA230, 20, 55); // Viel Heller, größere Reichweite
|
||||
this.mobileLight.position.set(this.center.x, this.center.y, this.center.z);
|
||||
this.mobileLight.castShadow = false; // Für Performance auf Mobile
|
||||
this.scene.add(this.mobileLight);
|
||||
} else {
|
||||
// --- Desktop wie gehabt
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const L = new THREE.PointLight(0xFFA230, 5, 30); L.castShadow = true;
|
||||
this.lights.push(L); this.scene.add(L);
|
||||
const L2 = new THREE.PointLight(0xFFA230, 5, 30); L2.castShadow = true;
|
||||
this.counterLights.push(L2); this.scene.add(L2);
|
||||
}
|
||||
}
|
||||
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
async loadSpinner(path, pos, rotDeg, color, opacity) {
|
||||
const { scene: obj } = await gltfLoader.loadAsync(path);
|
||||
obj.position.set(...pos);
|
||||
obj.rotation.set(
|
||||
THREE.MathUtils.degToRad(rotDeg[0]),
|
||||
THREE.MathUtils.degToRad(rotDeg[1]),
|
||||
THREE.MathUtils.degToRad(rotDeg[2])
|
||||
);
|
||||
obj.traverse(c => {
|
||||
c.visible = true;
|
||||
if (c.isMesh && c.material && c.material.isMeshStandardMaterial) {
|
||||
c.material.transparent = true;
|
||||
c.material.opacity = opacity;
|
||||
c.material.emissive = new THREE.Color(color);
|
||||
c.material.emissiveIntensity = 3.0;
|
||||
c.material.vertexColors = true;
|
||||
c.castShadow = true;
|
||||
}
|
||||
});
|
||||
this.scene.add(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
smoothTransition(dt) {
|
||||
let T = this.transition;
|
||||
if (this.connected) {
|
||||
T.targetEmission = 3.0; T.targetBobMult = 0.5;
|
||||
T.targetRotSpeed = 1.2; T.targetLightIntensity = 5;
|
||||
} else {
|
||||
T.targetEmission = 0.0; T.targetBobMult = 0.12;
|
||||
T.targetRotSpeed = 0.08; T.targetLightIntensity = 0;
|
||||
}
|
||||
const s = T.lerpSpeed * dt;
|
||||
T.emission += (T.targetEmission - T.emission) * s;
|
||||
T.bobMult += (T.targetBobMult - T.bobMult) * s;
|
||||
T.rotSpeed += (T.targetRotSpeed - T.rotSpeed) * s;
|
||||
T.lightIntensity += (T.targetLightIntensity - T.lightIntensity) * s;
|
||||
}
|
||||
|
||||
animate(dt, t) {
|
||||
this.smoothTransition(dt);
|
||||
const T = this.transition;
|
||||
const bob = Math.sin(t * 1.2) * T.bobMult;
|
||||
const baseY = this.baseY + bob;
|
||||
|
||||
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;
|
||||
|
||||
this.spinnerRed.traverse(c => {
|
||||
if (c.isMesh && c.material && c.material.isMeshStandardMaterial)
|
||||
c.material.emissiveIntensity = T.emission;
|
||||
});
|
||||
this.spinnerBlue.traverse(c => {
|
||||
if (c.isMesh && c.material && c.material.isMeshStandardMaterial)
|
||||
c.material.emissiveIntensity = T.emission;
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_MOBILE) {
|
||||
// Mobile: zentrales Licht direkt an den Spinner-Positionen halten
|
||||
if (this.mobileLight) {
|
||||
// In der Mitte zwischen beiden Spinners
|
||||
this.mobileLight.position.set(this.center.x, baseY + 0.4, this.center.z);
|
||||
this.mobileLight.intensity = T.lightIntensity * 3; // Noch heller als Desktop
|
||||
}
|
||||
} else {
|
||||
// Desktop: Normale Lichtbewegung
|
||||
for (let i = 0; i < this.lights.length; i++) {
|
||||
const ang = t * 0.8 + i * 2 * Math.PI / 3;
|
||||
this.lights[i].position.set(
|
||||
this.center.x + Math.cos(ang) * this.LIGHT_RADIUS,
|
||||
this.center.y + Math.sin(ang) * this.LIGHT_RADIUS,
|
||||
this.center.z
|
||||
);
|
||||
this.lights[i].intensity = T.lightIntensity;
|
||||
}
|
||||
for (let i = 0; i < this.counterLights.length; i++) {
|
||||
const ang = -t * 0.8 + i * 2 * Math.PI / 3;
|
||||
this.counterLights[i].position.set(
|
||||
this.center.x + Math.cos(ang) * this.LIGHT_RADIUS,
|
||||
this.center.y + Math.sin(ang) * this.LIGHT_RADIUS,
|
||||
this.center.z
|
||||
);
|
||||
this.counterLights[i].intensity = T.lightIntensity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket() {
|
||||
if (this.ws) this.ws.close();
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.ws = new WebSocket(`${wsProtocol}://${location.host}`);
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.connected = true;
|
||||
console.log("WebSocket connected!");
|
||||
});
|
||||
this.ws.addEventListener('message', async (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'spirit') {
|
||||
if (typeof msg.timeSinceSpawnMs === "number" && msg.timeSinceSpawnMs > 0) {
|
||||
// Initiale Verbindung: Mit Offset
|
||||
this.spawnSpiritWithOffset(msg.data, msg.timeSinceSpawnMs, msg.spiritIntervalMs);
|
||||
} else {
|
||||
// Normales Timer-Event
|
||||
this.spawnSpirit(msg.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
this.connected = false;
|
||||
console.warn("WebSocket closed. Reconnecting in " + this.reconnectDelay / 1000 + "s...");
|
||||
setTimeout(() => this.connectWebSocket(), this.reconnectDelay);
|
||||
});
|
||||
this.ws.addEventListener('error', (e) => {
|
||||
this.connected = false;
|
||||
console.error("WebSocket error", e);
|
||||
this.ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Jetzt Methoden am Spinner! ---
|
||||
async spawnSpirit(spiritData) {
|
||||
let spawnPos = {
|
||||
x: 0,
|
||||
y: 16.55,
|
||||
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();
|
||||
activeSpirits.push(spirit);
|
||||
}
|
||||
|
||||
async spawnSpiritWithOffset(spiritData, timeSinceSpawnMs = 0, spiritIntervalMs = 18000) {
|
||||
let startY = 17.35;
|
||||
let offset = (typeof timeSinceSpawnMs === 'number' && timeSinceSpawnMs > 0) ? timeSinceSpawnMs / 1000 : 0;
|
||||
let lifeTime = (spiritIntervalMs ? spiritIntervalMs : 18000) / 1000;
|
||||
const moveSpeed = 0.8;
|
||||
let spawnPos = { x: 0, y: startY - (moveSpeed * 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();
|
||||
spirit.clock.elapsedTime = offset;
|
||||
spirit.lifeTime = lifeTime;
|
||||
activeSpirits.push(spirit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- Spirit-Klasse ----
|
||||
class Spirit {
|
||||
constructor(scene, gltfScene, info, spawnPosition) {
|
||||
this.scene = scene;
|
||||
this.grp = new THREE.Group();
|
||||
this.gltf = gltfScene;
|
||||
this.info = info || {};
|
||||
this.spawnY = spawnPosition.y; // immer gleiche Start-Y
|
||||
this.clock = new THREE.Clock();
|
||||
this.isFading = true;
|
||||
this.lifeTime = 20; // Sekunden
|
||||
this.spiritMeshes = [];
|
||||
this.grp.add(this.gltf);
|
||||
this.gltf.rotation.x = -Math.PI;
|
||||
this.grp.position.set(spawnPosition.x, spawnPosition.y, spawnPosition.z - 0.6);
|
||||
|
||||
this.gltf.traverse((mesh) => {
|
||||
if (mesh.isMesh) {
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.originalMaterial = mesh.material.clone();
|
||||
mesh.material = mesh.material.clone();
|
||||
mesh.material.color.set(0xffffcc);
|
||||
mesh.material.opacity = 1.0;
|
||||
mesh.material.transparent = true;
|
||||
mesh.material.emissive?.set(0xffffcc);
|
||||
mesh.material.emissiveIntensity = 2.0;
|
||||
this.spiritMeshes.push(mesh);
|
||||
}
|
||||
});
|
||||
this._setupPicking();
|
||||
this.scene.add(this.grp);
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
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) {
|
||||
mesh.material.opacity = 1;
|
||||
mesh.material.color.lerp(mesh.userData.originalMaterial.color, t / 0.5);
|
||||
if (mesh.material.emissive)
|
||||
mesh.material.emissive.lerp(mesh.userData.originalMaterial.emissive || new THREE.Color(0x000000), t / 0.5);
|
||||
mesh.material.emissiveIntensity =
|
||||
2.0 * (1 - t / 0.5) +
|
||||
(mesh.userData.originalMaterial.emissiveIntensity || 1.0) * (t / 0.5);
|
||||
} else {
|
||||
mesh.material.opacity = mesh.userData.originalMaterial.opacity ?? 1.0;
|
||||
mesh.material.color.copy(mesh.userData.originalMaterial.color);
|
||||
if (mesh.material.emissive)
|
||||
mesh.material.emissive.copy(mesh.userData.originalMaterial.emissive || new THREE.Color(0x000000));
|
||||
mesh.material.emissiveIntensity =
|
||||
mesh.userData.originalMaterial.emissiveIntensity ?? 1.0;
|
||||
this.isFading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t > this.lifeTime) {
|
||||
this.dispose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.scene.remove(this.grp);
|
||||
this.gltf.traverse((mesh) => {
|
||||
if (mesh.isMesh) {
|
||||
mesh.geometry.dispose();
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((m) => m.dispose());
|
||||
} else {
|
||||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_setupPicking() {
|
||||
// Hier ein einfacher Ansatz: Mesh mit Info-Objekt merken!
|
||||
this.gltf.traverse(mesh => {
|
||||
if (mesh.isMesh) {
|
||||
mesh.userData._spiritInfo = this.info;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 });
|
||||
|
||||
spinnerController = new SpinnerController(scene);
|
||||
|
||||
animate();
|
||||
})();
|
||||
|
||||
// ---- Utility: GLBs laden ----
|
||||
async function loadGLB(path, pos, rotDeg, { receiveShadow = false, castShadow = false, emissive = null, visible = true, shadowOnly = false } = {}) {
|
||||
const { scene: obj } = await gltfLoader.loadAsync(path);
|
||||
obj.position.set(pos[0], pos[1], pos[2]);
|
||||
obj.rotation.set(
|
||||
THREE.MathUtils.degToRad(rotDeg[0]),
|
||||
THREE.MathUtils.degToRad(rotDeg[1]),
|
||||
THREE.MathUtils.degToRad(rotDeg[2])
|
||||
);
|
||||
obj.traverse(c => {
|
||||
c.visible = visible;
|
||||
if (c.isMesh) {
|
||||
c.castShadow = castShadow;
|
||||
c.receiveShadow = receiveShadow;
|
||||
if (shadowOnly) c.material = shadowOnlyMaterial;
|
||||
if (emissive && c.material && c.material.isMeshStandardMaterial) {
|
||||
c.material.emissive = new THREE.Color(emissive);
|
||||
c.material.emissiveIntensity = 1.0;
|
||||
}
|
||||
}
|
||||
});
|
||||
scene.add(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
// ---- Overlay-Logik ----
|
||||
let lastOverlaySpiritData = null;
|
||||
|
||||
// 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';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.innerHTML = `
|
||||
<button id="spirit-overlay-close" style="
|
||||
position:absolute; right:-3px; top:-7px;
|
||||
width:56px; height:56px;
|
||||
background:none; border:none; border-radius:50%;
|
||||
color:#fff; font-size:2.5em; cursor:pointer;
|
||||
line-height:1; display:flex; align-items:center; justify-content:center;
|
||||
box-shadow: 0 0 16px #0008;
|
||||
transition: background 0.18s;
|
||||
" title="Schließen" tabindex="0"
|
||||
onmouseover="this.style.background='rgba(255,255,255,0.10)'"
|
||||
onmouseout="this.style.background='none'"
|
||||
>×</button>
|
||||
<div class="spirit-info-flex">
|
||||
${spirit['Image URL'] ? `<img src="${spirit['Image URL']}" alt="Spirit Image">` : ''}
|
||||
<div class="spirit-info-content">
|
||||
<h2 style="margin:0 0 8px 0;">${spirit.Name || 'Spirit'}</h2>
|
||||
<b>${spirit.Kategorie || ''}</b><br><br>
|
||||
<b>Mythos:</b> ${spirit["Mythos/Legende"] || ''}<br><br>
|
||||
<b>Rolle:</b> ${spirit["Funktion/Rolle"] || ''}<br>
|
||||
<b>Charakter:</b> ${spirit.Charakter || ''}<br><br>
|
||||
${spirit.Herkunft ? '<i>' + spirit.Herkunft + '</i>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 activeSpirits) {
|
||||
spirit.gltf.traverse(mesh => {
|
||||
if (mesh.isMesh) allMeshes.push(mesh);
|
||||
});
|
||||
}
|
||||
const intersects = raycaster.intersectObjects(allMeshes, false);
|
||||
if (intersects.length > 0) {
|
||||
const mesh = intersects[0].object;
|
||||
if (mesh.userData._spiritInfo) {
|
||||
showSpiritOverlay(mesh.userData._spiritInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(now) {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
// Wenn noch nicht genug Zeit vergangen ist, skippen.
|
||||
if (now - lastAnim < 1000 / targetFps) return;
|
||||
|
||||
// Fange kleine Zeitverluste auf, um "drift" zu vermeiden:
|
||||
// lastAnim = now; // Original (verliert mit der Zeit Frames)
|
||||
while (now - lastAnim >= 1000 / targetFps) {
|
||||
lastAnim += 1000 / targetFps;
|
||||
}
|
||||
|
||||
const dt = clock.getDelta(), t = clock.getElapsedTime();
|
||||
if (spinnerController) spinnerController.animate(dt, t);
|
||||
for (let i = activeSpirits.length - 1; i >= 0; i--) {
|
||||
if (!activeSpirits[i].update(dt)) {
|
||||
activeSpirits.splice(i, 1);
|
||||
}
|
||||
}
|
||||
composer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
let el = document.getElementById('spirit-info');
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
});
|
||||
143
server/public/index.html
Normal file
143
server/public/index.html
Normal file
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>Summoner / Virtuelles interdimensionales Geisterteleportationsgerät</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.155.0/build/three.module.js",
|
||||
"GLTFLoader": "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/loaders/GLTFLoader.js",
|
||||
"DRACOLoader": "https://cdn.jsdelivr.net/npm/three@0.155.0/examples/jsm/loaders/DRACOLoader.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
#viewer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#spirit-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 1.6em;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.spirit-info-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
#spirit-info {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
overflow-x: hidden;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0,0,0,0.94);
|
||||
color: #fff;
|
||||
border-radius: 18px;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
box-shadow: 0 12px 64px #000a;
|
||||
width: 90vw;
|
||||
max-width: 98vw;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
max-height: 82dvh;
|
||||
overflow-y: auto;
|
||||
padding: 8vw 4vw 4vw 4vw;
|
||||
z-index: 9999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Standard: Bild oben, Text unten */
|
||||
.spirit-info-flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
#spirit-info img {
|
||||
display: block;
|
||||
margin: 0 auto 18px auto;
|
||||
max-width: 84vw;
|
||||
max-height: 30vh;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
#spirit-info .spirit-info-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* GROSSES DESKTOP-FENSTER ODER BREITES TABLET/PHONE landscape: Bild links, Text rechts */
|
||||
@media (min-width: 750px) and (pointer: fine), (pointer: coarse) and (orientation: landscape) and (min-width: 750px) {
|
||||
#spirit-info {
|
||||
width: 600px;
|
||||
max-width: calc(100vw - 64px);
|
||||
min-width: 320px;
|
||||
max-height: calc(100dvh - 64px);
|
||||
padding: 40px 40px 36px 40px;
|
||||
}
|
||||
.spirit-info-flex {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
}
|
||||
#spirit-info img {
|
||||
max-width: 220px;
|
||||
min-width: 120px;
|
||||
width: 35vw;
|
||||
max-height: 45vh;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
#spirit-info .spirit-info-content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 450px;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
server/server.js
Normal file
140
server/server.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const ws = require('ws');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// --- Server Setup ---
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = '0.0.0.0';
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new ws.Server({ server });
|
||||
|
||||
let spiritPos = 0;
|
||||
const SPIRIT_INTERVAL_MS = 18000; // 20 Sekunden
|
||||
let lastSpiritSpawn = Date.now();
|
||||
let spiritTimer = null;
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const SPIRITS_PATH = path.join(__dirname, '.', 'spirits', 'spirit_list.json');
|
||||
let spirits = [];
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
spirits = JSON.parse(fs.readFileSync(SPIRITS_PATH, 'utf8'));
|
||||
if (!Array.isArray(spirits) || spirits.length === 0) throw 'Spirit-Liste leer oder ungültig!';
|
||||
shuffleArray(spirits);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Spirits:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
function nextSpirit() {
|
||||
spiritPos++;
|
||||
if (spiritPos >= spirits.length) {
|
||||
shuffleArray(spirits);
|
||||
spiritPos = 0;
|
||||
}
|
||||
lastSpiritSpawn = Date.now();
|
||||
}
|
||||
|
||||
// --- WebSocket Logik ---
|
||||
function pushSpiritToAllClients() {
|
||||
const spirit = spirits[spiritPos];
|
||||
lastSpiritSpawn = Date.now();
|
||||
const payload = JSON.stringify({
|
||||
type: 'spirit',
|
||||
data: spirit,
|
||||
timeSinceSpawnMs: 0,
|
||||
spiritIntervalMs: SPIRIT_INTERVAL_MS
|
||||
});
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === ws.OPEN) {
|
||||
client.send(payload);
|
||||
}
|
||||
});
|
||||
//console.log(`[Server] Spirit "${spirit.Name}" gesendet (${spiritPos + 1}/${spirits.length})`);
|
||||
}
|
||||
|
||||
// --- Timer ---
|
||||
function startSpiritTimer() {
|
||||
if (!spiritTimer) {
|
||||
spiritTimer = setInterval(() => {
|
||||
// Vorher weiterzählen, DANN pushen!
|
||||
nextSpirit();
|
||||
pushSpiritToAllClients();
|
||||
}, SPIRIT_INTERVAL_MS);
|
||||
//console.log('[Server] Spirit-Timer gestartet');
|
||||
}
|
||||
}
|
||||
|
||||
// Timer stoppen
|
||||
function stopSpiritTimer() {
|
||||
if (spiritTimer) {
|
||||
clearInterval(spiritTimer);
|
||||
spiritTimer = null;
|
||||
//console.log('[Server] Spirit-Timer gestoppt');
|
||||
nextSpirit(); // Rotiert für Singleuser wie gewünscht
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Gibt es noch offene Clients?
|
||||
function hasOpenClients() {
|
||||
let found = false;
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === ws.OPEN) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// --- WebSocket Logik ---
|
||||
wss.on('connection', (socket) => {
|
||||
//console.log('[Server] Neuer Client verbunden');
|
||||
|
||||
// Zeit seit letztem Spirit-Spawn:
|
||||
const now = Date.now();
|
||||
const timeSinceSpawnMs = now - lastSpiritSpawn;
|
||||
const spirit = spirits[spiritPos];
|
||||
|
||||
// Sende Spirit, Zeitdifferenz und Intervall an neuen Client
|
||||
socket.send(JSON.stringify({
|
||||
type: 'spirit',
|
||||
data: spirit,
|
||||
timeSinceSpawnMs,
|
||||
spiritIntervalMs: SPIRIT_INTERVAL_MS
|
||||
}));
|
||||
|
||||
|
||||
// Timer starten, falls es der erste Client ist:
|
||||
if (wss.clients.size === 1) {
|
||||
// NICHT doppelt senden!
|
||||
// NUR starten, nicht noch mal pushen!
|
||||
startSpiritTimer();
|
||||
}
|
||||
|
||||
// Verbindung verloren: Timer ggf. stoppen
|
||||
socket.on('close', () => {
|
||||
setTimeout(() => {
|
||||
if (!hasOpenClients()) {
|
||||
stopSpiritTimer();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Server Start, initialen Spirit pushen (optional, für "Demo" ohne Client)
|
||||
pushSpiritToAllClients();
|
||||
|
||||
// --- Server Start ---
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`[Server] Läuft auf http://${HOST}:${PORT}`);
|
||||
});
|
||||
1082
server/spirits/spirit_list.json
Normal file
1082
server/spirits/spirit_list.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user