2025-05-28 02:24:37 +02:00
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" ;
2025-05-28 03:15:51 +02:00
// ---- Basis Three.js Szene ----
2025-05-28 02:28:03 +02:00
const scene = new THREE . Scene ( ) ;
2025-05-28 03:28:58 +02:00
const ASPECT = 3 / 2 , SCALE = 15 ;
const hw = SCALE / 2 , hh = ( SCALE / ASPECT ) / 2 ;
2025-05-28 02:28:03 +02:00
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 ) ;
2025-05-28 02:24:37 +02:00
2025-05-28 02:28:03 +02:00
const container = document . getElementById ( 'viewer' ) ;
2025-05-28 03:28:58 +02:00
const renderer = new THREE . WebGLRenderer ( { antialias : true } ) ;
2025-05-28 02:28:03 +02:00
renderer . outputColorSpace = THREE . SRGBColorSpace ;
renderer . shadowMap . enabled = true ;
renderer . shadowMap . type = THREE . PCFSoftShadowMap ;
container . appendChild ( renderer . domElement ) ;
2025-05-28 03:15:51 +02:00
// ---- Postprocessing ----
2025-05-28 05:44:33 +02:00
const foliageTexture = new THREE . TextureLoader ( ) . load ( 'assets/images/sprites/foliage.png' ) ;
2025-05-28 02:28:03 +02:00
foliageTexture . colorSpace = THREE . SRGBColorSpace ;
const FoliageOverlayShader = {
2025-05-28 03:28:58 +02:00
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 ) ; } `
2025-05-28 02:28:03 +02:00
} ;
const foliageOverlayPass = new ShaderPass ( FoliageOverlayShader ) ;
const composer = new EffectComposer ( renderer ) ;
composer . addPass ( new RenderPass ( scene , camera ) ) ;
composer . addPass ( foliageOverlayPass ) ;
2025-05-28 03:28:58 +02:00
composer . addPass ( new UnrealBloomPass ( new THREE . Vector2 ( container . clientWidth , container . clientHeight ) , 0.8 , 0.2 , 0.4 ) ) ;
2025-05-28 02:28:03 +02:00
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 ) ) ;
2025-05-28 03:15:51 +02:00
// ---- Resize Handler ----
2025-05-28 03:28:58 +02:00
function onResize ( ) {
2025-05-28 04:26:24 +02:00
const winW = window . innerWidth , winH = window . innerHeight ;
const aspect = 3 / 2 ;
2025-05-28 05:38:15 +02:00
let renderW , renderH , styleW , styleH ;
2025-05-28 04:26:24 +02:00
if ( winW / winH > aspect ) {
2025-05-28 05:38:15 +02:00
// Querformat (breit) → CONTAIN
2025-05-28 04:27:57 +02:00
renderH = winH ;
renderW = winH * aspect ;
2025-05-28 05:38:15 +02:00
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 ` ;
2025-05-28 04:26:24 +02:00
}
2025-05-28 05:38:15 +02:00
2025-05-28 04:27:57 +02:00
renderer . setSize ( renderW , renderH , false ) ;
2025-05-28 04:26:24 +02:00
composer . setSize ( renderW , renderH ) ;
2025-05-28 05:38:15 +02:00
renderer . domElement . style . width = styleW ;
renderer . domElement . style . height = styleH ;
2025-05-28 04:26:24 +02:00
renderer . domElement . style . left = '50%' ;
renderer . domElement . style . top = '50%' ;
renderer . domElement . style . transform = 'translate(-50%, -50%)' ;
renderer . setScissorTest ( false ) ;
2025-05-28 03:28:58 +02:00
const dpr = window . devicePixelRatio || 1 ;
renderer . setPixelRatio ( dpr ) ;
composer . setPixelRatio ( dpr ) ;
2025-05-28 04:26:24 +02:00
2025-05-28 03:28:58 +02:00
camera . updateProjectionMatrix ( ) ;
2025-05-28 02:28:03 +02:00
}
2025-05-28 03:28:58 +02:00
window . addEventListener ( 'resize' , onResize ) ;
2025-05-28 02:28:03 +02:00
onResize ( ) ;
2025-05-28 03:28:58 +02:00
// ---- Environment ----
2025-05-28 02:28:03 +02:00
const texLoader = new THREE . TextureLoader ( ) ;
2025-05-28 03:28:58 +02:00
const pmremGen = new THREE . PMREMGenerator ( renderer ) ;
2025-05-28 05:44:18 +02:00
texLoader . load ( 'assets/images/hdri/environment.jpg' , tex => {
2025-05-28 03:28:58 +02:00
const envRT = pmremGen . fromEquirectangular ( tex ) . texture ;
scene . environment = envRT ;
scene . background = envRT ;
tex . dispose ( ) ;
pmremGen . dispose ( ) ;
2025-05-28 02:28:03 +02:00
} ) ;
2025-05-28 03:28:58 +02:00
// ---- Licht, Shadow-Only-Material, Loader ----
2025-05-28 02:28:03 +02:00
const sun = new THREE . DirectionalLight ( 0xFFA230 , 2 ) ;
sun . position . set ( 21 , - 25 , 30 ) ;
sun . castShadow = true ;
2025-05-28 03:28:58 +02:00
sun . shadow . mapSize . width = 2048 ;
2025-05-28 02:28:03 +02:00
sun . shadow . mapSize . height = 2048 ;
2025-05-28 03:28:58 +02:00
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 ;
2025-05-28 02:28:03 +02:00
scene . add ( sun ) ;
2025-05-28 03:28:58 +02:00
const shadowOnlyMaterial = new THREE . MeshBasicMaterial ( { color : 0x000000 , opacity : 0.01 , transparent : true , depthWrite : false } ) ;
const draco = new DRACOLoader ( ) ;
2025-05-28 02:28:03 +02:00
draco . setDecoderPath ( 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/' ) ;
const gltfLoader = new GLTFLoader ( ) ;
gltfLoader . setDRACOLoader ( draco ) ;
2025-05-28 03:40:56 +02:00
// Virtuelles Interdimensionales Geisterteleportationsgerät
2025-05-28 03:28:58 +02:00
class SpinnerController {
2025-05-28 03:36:39 +02:00
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 . baseY = 16.55 ;
this . clock = new THREE . Clock ( ) ;
this . ws = null ;
this . reconnectDelay = 2000 ;
2025-05-28 03:40:56 +02:00
this . connected = false ; // Verbindungsstatus
2025-05-28 03:44:22 +02:00
// 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 // 1/x Sekunden bis Ziel (hier: ca. 3s)
} ;
2025-05-28 03:36:39 +02:00
this . init ( ) ;
2025-05-28 02:28:03 +02:00
}
2025-05-28 03:36:39 +02:00
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 ) ;
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 ) ;
2025-05-28 03:28:58 +02:00
}
2025-05-28 03:36:39 +02:00
this . connectWebSocket ( ) ;
2025-05-28 03:28:58 +02:00
}
2025-05-28 03:10:21 +02:00
2025-05-28 03:36:39 +02:00
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 ;
2025-05-28 03:35:18 +02:00
}
2025-05-28 03:36:39 +02:00
2025-05-28 03:44:22 +02:00
// --- Werte sanft angleichen ---
smoothTransition ( dt ) {
let T = this . transition ;
// Zielwerte setzen
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 ;
2025-05-28 03:46:31 +02:00
T . targetLightIntensity = 0 ;
2025-05-28 03:44:22 +02:00
}
// 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 ;
T . lightIntensity += ( T . targetLightIntensity - T . lightIntensity ) * s ;
}
2025-05-28 03:40:56 +02:00
2025-05-28 03:44:22 +02:00
animate ( dt , t ) {
this . smoothTransition ( dt ) ;
2025-05-28 03:40:56 +02:00
2025-05-28 03:44:22 +02:00
const T = this . transition ;
const bob = Math . sin ( t * 1.2 ) * T . bobMult ;
2025-05-28 03:36:39 +02:00
const baseY = this . baseY + bob ;
2025-05-28 03:40:56 +02:00
// Spinner
2025-05-28 03:36:39 +02:00
if ( this . spinnerRed && this . spinnerBlue ) {
this . spinnerRed . position . y = baseY + 0.8 ;
this . spinnerBlue . position . y = baseY ;
2025-05-28 03:44:22 +02:00
this . spinnerRed . rotation . y -= T . rotSpeed * dt ;
this . spinnerBlue . rotation . y += T . rotSpeed * dt ;
2025-05-28 03:40:56 +02:00
// Emission auf beide Spinner anwenden
this . spinnerRed . traverse ( c => {
if ( c . isMesh && c . material && c . material . isMeshStandardMaterial )
2025-05-28 03:44:22 +02:00
c . material . emissiveIntensity = T . emission ;
2025-05-28 03:40:56 +02:00
} ) ;
this . spinnerBlue . traverse ( c => {
if ( c . isMesh && c . material && c . material . isMeshStandardMaterial )
2025-05-28 03:44:22 +02:00
c . material . emissiveIntensity = T . emission ;
2025-05-28 03:40:56 +02:00
} ) ;
2025-05-28 03:36:39 +02:00
}
2025-05-28 03:44:22 +02:00
// Rotierende Lichter (jetzt mit smooth intensity und Speed)
2025-05-28 03:36:39 +02:00
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
) ;
2025-05-28 03:44:22 +02:00
this . lights [ i ] . intensity = T . lightIntensity ;
2025-05-28 03:36:39 +02:00
}
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
) ;
2025-05-28 03:44:22 +02:00
this . counterLights [ i ] . intensity = T . lightIntensity ;
2025-05-28 03:36:39 +02:00
}
}
connectWebSocket ( ) {
2025-05-28 03:40:56 +02:00
if ( this . ws ) this . ws . close ( ) ;
this . ws = new WebSocket ( ` ws:// ${ location . host } ` ) ;
this . ws . addEventListener ( 'open' , ( ) => {
this . connected = true ;
2025-05-28 03:36:39 +02:00
console . log ( "WebSocket connected!" ) ;
} ) ;
2025-05-28 03:40:56 +02:00
this . ws . addEventListener ( 'message' , async ( event ) => {
2025-05-28 03:36:39 +02:00
const msg = JSON . parse ( event . data ) ;
if ( msg . type === 'spirit' ) {
2025-05-28 06:17:37 +02:00
if ( typeof msg . timeSinceSpawnMs === "number" && msg . timeSinceSpawnMs > 0 ) {
// Initiale Verbindung: Mit Offset
spawnSpiritWithOffset ( msg . data , msg . timeSinceSpawnMs , msg . spiritIntervalMs ) ;
} else {
// Normales Timer-Event
spawnSpirit ( msg . data ) ;
}
2025-05-28 03:36:39 +02:00
}
} ) ;
2025-05-28 03:40:56 +02:00
this . ws . addEventListener ( 'close' , ( ) => {
this . connected = false ;
2025-05-28 03:36:39 +02:00
console . warn ( "WebSocket closed. Reconnecting in " + this . reconnectDelay / 1000 + "s..." ) ;
2025-05-28 03:40:56 +02:00
setTimeout ( ( ) => this . connectWebSocket ( ) , this . reconnectDelay ) ;
2025-05-28 03:36:39 +02:00
} ) ;
2025-05-28 03:40:56 +02:00
this . ws . addEventListener ( 'error' , ( e ) => {
this . connected = false ;
2025-05-28 03:36:39 +02:00
console . error ( "WebSocket error" , e ) ;
2025-05-28 03:40:56 +02:00
this . ws . close ( ) ;
2025-05-28 03:36:39 +02:00
} ) ;
2025-05-28 03:28:58 +02:00
}
}
2025-05-28 03:15:51 +02:00
2025-05-28 06:08:13 +02:00
2025-05-28 03:15:51 +02:00
// ---- Spirit-Klasse ----
2025-05-28 02:58:22 +02:00
class Spirit {
2025-05-28 03:28:58 +02:00
constructor ( scene , gltfScene , info , spawnPosition ) {
this . scene = scene ;
this . grp = new THREE . Group ( ) ;
this . gltf = gltfScene ;
this . info = info || { } ;
this . spawnY = spawnPosition . y ;
this . clock = new THREE . Clock ( ) ;
this . isFading = true ;
2025-05-28 05:41:41 +02:00
this . lifeTime = 20 ; // Sekunden
2025-05-28 03:28:58 +02:00
this . spiritMeshes = [ ] ;
this . grp . add ( this . gltf ) ;
// exakt wie im Original: rotate, platzieren, leicht nach hinten
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 ) ;
}
} ) ;
2025-05-28 05:22:36 +02:00
this . _setupPicking ( ) ;
2025-05-28 04:55:27 +02:00
this . scene . add ( this . grp ) ;
2025-05-28 03:28:58 +02:00
}
2025-05-28 02:58:22 +02:00
2025-05-28 03:28:58 +02:00
update ( dt ) {
const t = this . clock . getElapsedTime ( ) ;
// 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 ;
}
}
2025-05-28 02:58:22 +02:00
}
2025-05-28 03:28:58 +02:00
// Vertikales Despawn-Movement
2025-05-28 05:41:21 +02:00
this . grp . position . y -= 0.8 * dt ;
2025-05-28 03:28:58 +02:00
// Nach Lebenszeit entfernen
if ( t > this . lifeTime ) {
this . dispose ( ) ;
return false ;
}
return true ;
2025-05-28 02:58:22 +02:00
}
2025-05-28 03:28:58 +02:00
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 ( ) ;
}
}
} ) ;
2025-05-28 02:58:22 +02:00
}
2025-05-28 05:22:36 +02:00
_setupPicking ( ) {
// Hier ein einfacher Ansatz: Mesh mit Info-Objekt merken!
this . gltf . traverse ( mesh => {
if ( mesh . isMesh ) {
mesh . userData . _spiritInfo = this . info ;
}
} ) ;
}
2025-05-28 02:28:03 +02:00
}
2025-05-28 03:28:58 +02:00
// ---- Szene initialisieren ----
let spinnerController ;
let landscape , torigate , shadowTree ;
const activeSpirits = [ ] ;
const clock = new THREE . Clock ( ) ;
2025-05-28 03:20:02 +02:00
2025-05-28 03:28:58 +02:00
( 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 } ) ;
2025-05-28 03:20:02 +02:00
2025-05-28 03:28:58 +02:00
spinnerController = new SpinnerController ( scene ) ;
2025-05-28 03:20:02 +02:00
2025-05-28 03:28:58 +02:00
animate ( ) ;
} ) ( ) ;
2025-05-28 03:20:02 +02:00
2025-05-28 03:28:58 +02:00
// ---- 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 ;
2025-05-28 03:20:02 +02:00
}
2025-05-28 03:28:58 +02:00
// ---- Spirit spawnen ----
async function spawnSpirit ( spiritData ) {
2025-05-28 03:58:16 +02:00
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 ) ;
2025-05-28 03:28:58 +02:00
const spirit = new Spirit ( scene , gltfScene , spiritData , spawnPos ) ;
activeSpirits . push ( spirit ) ;
2025-05-28 05:22:36 +02:00
//updateSpiritOverlay(spiritData);
2025-05-28 03:20:02 +02:00
}
2025-05-28 02:09:33 +02:00
2025-05-28 06:08:13 +02:00
async function spawnSpiritWithOffset ( spiritData , timeSinceSpawnMs = 0 , spiritIntervalMs = 20000 ) {
2025-05-28 06:21:27 +02:00
// Standard-Spawnhöhe:
2025-05-28 06:33:54 +02:00
let startY = spinnerController && spinnerController . spinnerRed ? spinnerController . spinnerRed . position . y - 1.5 : 15 ; //changed from -1.5 offset to compensate for lag
2025-05-28 06:21:27 +02:00
// Offset in Sekunden:
let offset = ( typeof timeSinceSpawnMs === 'number' && timeSinceSpawnMs > 0 ) ? timeSinceSpawnMs / 1000 : 0 ;
// Die Lebenszeit des Spirits:
2025-05-28 06:08:13 +02:00
let lifeTime = ( spiritIntervalMs ? spiritIntervalMs : 20000 ) / 1000 ;
2025-05-28 06:21:27 +02:00
// Y-Verschiebung pro Sekunde (wie im update!):
const despawnSpeed = 0.8 ; // ACHTUNG: identisch halten mit update()!
// Spawn-Position anpassen:
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 ) ;
2025-05-28 06:08:13 +02:00
const spirit = new Spirit ( scene , gltfScene , spiritData , spawnPos ) ;
spirit . clock . start ( ) ;
if ( offset > 0 && offset < lifeTime ) {
2025-05-28 06:21:27 +02:00
spirit . clock . elapsedTime = offset ;
2025-05-28 06:08:13 +02:00
}
spirit . lifeTime = lifeTime ;
activeSpirits . push ( spirit ) ;
}
2025-05-28 05:22:36 +02:00
// ---- Overlay-Logik ----
let lastOverlaySpiritData = null ;
// Overlay zentriert in der Mitte mit Schließen-X
function showSpiritOverlay ( spirit ) {
2025-05-28 05:40:32 +02:00
// 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 ) ;
2025-05-28 03:28:58 +02:00
let el = document . getElementById ( 'spirit-info' ) ;
if ( ! el ) {
el = document . createElement ( 'div' ) ;
el . id = 'spirit-info' ;
el . style = `
2025-05-28 05:22:36 +02:00
position : fixed ;
left : 50 % ; top : 50 % ;
transform : translate ( - 50 % , - 50 % ) ;
color : white ;
2025-05-28 05:40:32 +02:00
background : rgba ( 0 , 0 , 0 , 0.94 ) ;
padding : 28 px 36 px 24 px 36 px ;
border - radius : 18 px ;
2025-05-28 05:22:36 +02:00
font - family : 'Segoe UI' , sans - serif ;
z - index : 9999 ;
2025-05-28 05:40:32 +02:00
max - width : 560 px ;
min - width : 320 px ;
box - shadow : 0 12 px 64 px # 000 a ;
2025-05-28 05:22:36 +02:00
text - align : left ;
2025-05-28 03:28:58 +02:00
` ;
document . body . appendChild ( el ) ;
}
2025-05-28 03:58:06 +02:00
el . innerHTML = `
2025-05-28 05:22:36 +02:00
< button id = "spirit-overlay-close" style = "
2025-05-28 05:40:32 +02:00
position : absolute ; right : - 16 px ; top : - 16 px ;
width : 56 px ; height : 56 px ;
background : none ; border : none ; border - radius : 50 % ;
color : # fff ; font - size : 2.5 em ; cursor : pointer ;
line - height : 1 ; display : flex ; align - items : center ; justify - content : center ;
box - shadow : 0 0 16 px # 0008 ;
transition : background 0.18 s ;
" title=" Schließen " tabindex=" 0 "
onmouseover = "this.style.background='rgba(255,255,255,0.10)'"
onmouseout = "this.style.background='none'"
> & times ; < / b u t t o n >
$ { spirit [ 'Image URL' ] ? ` <img src=" ${ spirit [ 'Image URL' ] } " alt="Spirit Image" style="display:block; margin:0 auto 18px auto; max-width:240px; max-height:180px; border-radius:9px; background:#222;"> ` : '' }
2025-05-28 05:22:36 +02:00
< h2 style = 'padding:0; margin:0 0 8px 0; font-weight:700; letter-spacing:0.04em;' > $ { spirit . Name || 'Spirit' } < / h 2 >
< b > $ { spirit . Kategorie || '' } < / b > < b r > < b r >
< b > Mythos : < / b > $ { s p i r i t [ " M y t h o s / L e g e n d e " ] | | ' ' } < b r > < b r >
< b > Rolle : < / b > $ { s p i r i t [ " F u n k t i o n / R o l l e " ] | | ' ' } < b r >
< b > Charakter : < / b > $ { s p i r i t . C h a r a k t e r | | ' ' } < b r > < b r >
$ { spirit . Herkunft ? '<i>' + spirit . Herkunft + '</i>' : '' }
2025-05-28 05:04:28 +02:00
` ;
2025-05-28 05:22:36 +02:00
el . style . display = "block" ;
lastOverlaySpiritData = spirit ;
2025-05-28 05:40:32 +02:00
// 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 ( ) ;
2025-05-28 05:22:36 +02:00
} ;
2025-05-28 05:40:32 +02:00
// 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 ;
2025-05-28 04:57:03 +02:00
}
2025-05-28 05:22:36 +02:00
// 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...)
2025-05-28 03:28:58 +02:00
// ---- Render-Loop ----
function animate ( ) {
const dt = clock . getDelta ( ) , t = clock . getElapsedTime ( ) ;
// Spinner-Animation & Netzwerk
if ( spinnerController ) spinnerController . animate ( dt , t ) ;
// Update & remove expired spirits:
for ( let i = activeSpirits . length - 1 ; i >= 0 ; i -- ) {
if ( ! activeSpirits [ i ] . update ( dt ) ) {
activeSpirits . splice ( i , 1 ) ;
}
}
composer . render ( scene , camera ) ;
2025-05-28 03:30:24 +02:00
requestAnimationFrame ( animate ) ;
2025-05-28 05:22:36 +02:00
}
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' ) {
let el = document . getElementById ( 'spirit-info' ) ;
if ( el ) el . style . display = "none" ;
}
} ) ;