diff --git a/package-lock.json b/package-lock.json index 50883f057..6182585a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "express-hello-world", "version": "1.0.0", "license": "MIT", "dependencies": { diff --git a/public/animations.js b/public/animations.js new file mode 100644 index 000000000..6c7fb3378 --- /dev/null +++ b/public/animations.js @@ -0,0 +1,312 @@ +// Mortéz World - Cinematic Animations +let camera, scene; +let isIntroPlaying = true; +let introComplete = false; + +const ANIMATION_CONFIG = { + introDuration: 8000, + titleFadeInStart: 2000, + titleFadeInDuration: 2000, + titleFadeOutStart: 6000, + titleFadeOutDuration: 1500, + buttonFadeInStart: 7000, + buttonFadeInDuration: 1000 +}; + +function initAnimations(sceneAPI) { + camera = sceneAPI.getCamera(); + scene = sceneAPI.getScene(); + + // Start intro sequence + playIntroSequence(); +} + +function playIntroSequence() { + const startTime = Date.now(); + const startPosition = new THREE.Vector3(0, 25, 120); + const endPosition = new THREE.Vector3(0, 15, 60); + const lookAtTarget = new THREE.Vector3(0, 10, 0); + + camera.position.copy(startPosition); + camera.lookAt(lookAtTarget); + + // Show loading overlay + const overlay = document.getElementById('intro-overlay'); + const title = document.getElementById('intro-title'); + const subtitle = document.getElementById('intro-subtitle'); + const enterButton = document.getElementById('enter-button'); + + if (overlay) overlay.style.display = 'flex'; + + function animateIntro() { + if (!isIntroPlaying) return; + + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / ANIMATION_CONFIG.introDuration, 1); + const eased = easeInOutQuad(progress); + + // Camera movement + camera.position.lerpVectors(startPosition, endPosition, eased); + camera.lookAt(lookAtTarget); + + // Title fade in + if (elapsed >= ANIMATION_CONFIG.titleFadeInStart && elapsed < ANIMATION_CONFIG.titleFadeInStart + ANIMATION_CONFIG.titleFadeInDuration) { + const titleProgress = (elapsed - ANIMATION_CONFIG.titleFadeInStart) / ANIMATION_CONFIG.titleFadeInDuration; + const titleOpacity = easeInOutQuad(titleProgress); + if (title) title.style.opacity = titleOpacity; + if (subtitle) subtitle.style.opacity = titleOpacity * 0.8; + } + + // Title fade out + if (elapsed >= ANIMATION_CONFIG.titleFadeOutStart) { + const fadeOutProgress = (elapsed - ANIMATION_CONFIG.titleFadeOutStart) / ANIMATION_CONFIG.titleFadeOutDuration; + const fadeOutOpacity = 1 - easeInOutQuad(Math.min(fadeOutProgress, 1)); + if (title) title.style.opacity = fadeOutOpacity; + if (subtitle) subtitle.style.opacity = fadeOutOpacity * 0.8; + } + + // Button fade in + if (elapsed >= ANIMATION_CONFIG.buttonFadeInStart) { + const buttonProgress = (elapsed - ANIMATION_CONFIG.buttonFadeInStart) / ANIMATION_CONFIG.buttonFadeInDuration; + const buttonOpacity = easeInOutQuad(Math.min(buttonProgress, 1)); + if (enterButton) { + enterButton.style.opacity = buttonOpacity; + enterButton.style.pointerEvents = buttonOpacity > 0.5 ? 'auto' : 'none'; + } + } + + // Lightning flash effect + if (elapsed > 3000 && elapsed < 3100) { + triggerLightningFlash(); + } + if (elapsed > 5500 && elapsed < 5600) { + triggerLightningFlash(); + } + + if (progress < 1) { + requestAnimationFrame(animateIntro); + } else { + // Intro complete, wait for user interaction + introComplete = true; + } + } + + animateIntro(); + + // Setup enter button + if (enterButton) { + enterButton.addEventListener('click', () => { + endIntroSequence(); + }); + + // Add hover effects + enterButton.addEventListener('mouseenter', () => { + if (window.audioAPI) { + window.audioAPI.playSound('hover'); + } + triggerButtonHoverEffect(enterButton); + }); + } + + // Allow skip with spacebar + document.addEventListener('keydown', (e) => { + if (e.code === 'Space' && isIntroPlaying) { + endIntroSequence(); + } + }); +} + +function endIntroSequence() { + isIntroPlaying = false; + + const overlay = document.getElementById('intro-overlay'); + const uiContainer = document.getElementById('ui-container'); + + // Fade out overlay + if (overlay) { + overlay.style.opacity = '0'; + setTimeout(() => { + overlay.style.display = 'none'; + }, 1000); + } + + // Fade in UI + if (uiContainer) { + setTimeout(() => { + uiContainer.style.opacity = '1'; + }, 500); + } + + // Play entrance sound + if (window.audioAPI) { + window.audioAPI.playSound('enter'); + window.audioAPI.startAmbience(); + } + + // Show welcome message + if (window.interactionAPI) { + setTimeout(() => { + window.interactionAPI.showMessage("Explore the mansion. Click to interact."); + }, 1500); + } +} + +function triggerLightningFlash() { + const flash = document.getElementById('lightning-flash'); + if (flash) { + flash.style.opacity = '0.3'; + setTimeout(() => { + flash.style.opacity = '0'; + }, 100); + } + + // Increase scene lighting briefly + if (scene) { + scene.traverse((object) => { + if (object.isLight && object.type === 'DirectionalLight') { + const originalIntensity = object.intensity; + object.intensity = originalIntensity * 3; + setTimeout(() => { + object.intensity = originalIntensity; + }, 100); + } + }); + } + + // Play thunder sound + if (window.audioAPI) { + window.audioAPI.playSound('thunder'); + } +} + +function triggerButtonHoverEffect(button) { + // Create ripple effect + const ripple = document.createElement('div'); + ripple.className = 'button-ripple'; + button.appendChild(ripple); + + setTimeout(() => { + ripple.remove(); + }, 600); + + // Flicker lights in scene + if (scene) { + scene.traverse((object) => { + if (object.isLight && object.type === 'PointLight') { + const originalIntensity = object.intensity; + object.intensity = originalIntensity * 1.5; + setTimeout(() => { + object.intensity = originalIntensity; + }, 200); + } + }); + } +} + +function animateCameraTo(targetPosition, lookAtPosition, duration = 2000) { + const startPosition = camera.position.clone(); + const startLookAt = new THREE.Vector3(0, 10, 0); + const startTime = Date.now(); + + function animate() { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeInOutCubic(progress); + + camera.position.lerpVectors(startPosition, targetPosition, eased); + + const currentLookAt = new THREE.Vector3(); + currentLookAt.lerpVectors(startLookAt, lookAtPosition, eased); + camera.lookAt(currentLookAt); + + if (progress < 1) { + requestAnimationFrame(animate); + } + } + + animate(); +} + +function createFloatingAnimation(object, amplitude = 0.5, speed = 1) { + const startY = object.position.y; + const startTime = Date.now(); + + function animate() { + const elapsed = (Date.now() - startTime) / 1000; + object.position.y = startY + Math.sin(elapsed * speed) * amplitude; + requestAnimationFrame(animate); + } + + animate(); +} + +function createRotatingAnimation(object, speed = 0.5) { + function animate() { + object.rotation.y += speed * 0.01; + requestAnimationFrame(animate); + } + + animate(); +} + +function createPulsingLight(light, minIntensity, maxIntensity, speed = 1) { + const startTime = Date.now(); + + function animate() { + const elapsed = (Date.now() - startTime) / 1000; + const intensity = minIntensity + (maxIntensity - minIntensity) * (Math.sin(elapsed * speed) * 0.5 + 0.5); + light.intensity = intensity; + requestAnimationFrame(animate); + } + + animate(); +} + +function createScreenShake(intensity = 0.5, duration = 500) { + const startPosition = camera.position.clone(); + const startTime = Date.now(); + + function shake() { + const elapsed = Date.now() - startTime; + if (elapsed < duration) { + const progress = elapsed / duration; + const currentIntensity = intensity * (1 - progress); + + camera.position.x = startPosition.x + (Math.random() - 0.5) * currentIntensity; + camera.position.y = startPosition.y + (Math.random() - 0.5) * currentIntensity; + camera.position.z = startPosition.z + (Math.random() - 0.5) * currentIntensity; + + requestAnimationFrame(shake); + } else { + camera.position.copy(startPosition); + } + } + + shake(); +} + +// Easing functions +function easeInOutQuad(t) { + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; +} + +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +function easeInOutQuart(t) { + return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2; +} + +// Export API +window.animationAPI = { + init: initAnimations, + isIntroPlaying: () => isIntroPlaying, + endIntro: endIntroSequence, + animateCameraTo: animateCameraTo, + createFloatingAnimation: createFloatingAnimation, + createRotatingAnimation: createRotatingAnimation, + createPulsingLight: createPulsingLight, + createScreenShake: createScreenShake, + triggerLightningFlash: triggerLightningFlash +}; diff --git a/public/audio.js b/public/audio.js new file mode 100644 index 000000000..37d92efef --- /dev/null +++ b/public/audio.js @@ -0,0 +1,396 @@ +// Mortéz World - Audio System +let audioContext; +let sounds = {}; +let ambienceLoop; +let isAudioInitialized = false; + +const AUDIO_CONFIG = { + masterVolume: 0.7, + ambienceVolume: 0.3, + sfxVolume: 0.5, + spatialRolloff: 2 +}; + +function initAudio() { + // Create audio context on user interaction + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + // Create procedural sounds + createProceduralSounds(); + + isAudioInitialized = true; +} + +function createProceduralSounds() { + // Thunder sound + sounds.thunder = createThunderSound(); + + // Hover sound + sounds.hover = createHoverSound(); + + // Click sound + sounds.click = createClickSound(); + + // Door creak sound + sounds.door = createDoorSound(); + + // Enter/transition sound + sounds.enter = createEnterSound(); + + // Rotate sound + sounds.rotate = createRotateSound(); +} + +function createThunderSound() { + return () => { + if (!audioContext) return; + + const duration = 2; + const now = audioContext.currentTime; + + // Create noise for thunder rumble + const bufferSize = audioContext.sampleRate * duration; + const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate); + const data = buffer.getChannelData(0); + + // Generate brown noise for deep rumble + let lastOut = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + data[i] = (lastOut + (0.02 * white)) / 1.02; + lastOut = data[i]; + data[i] *= 3.5; + } + + const noise = audioContext.createBufferSource(); + noise.buffer = buffer; + + // Filter for low frequencies + const filter = audioContext.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 200; + filter.Q.value = 1; + + // Envelope + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(AUDIO_CONFIG.sfxVolume * 0.8, now + 0.1); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + noise.connect(filter); + filter.connect(gainNode); + gainNode.connect(audioContext.destination); + + noise.start(now); + noise.stop(now + duration); + }; +} + +function createHoverSound() { + return () => { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 0.1; + + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(800, now); + oscillator.frequency.exponentialRampToValueAtTime(1200, now + duration); + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(AUDIO_CONFIG.sfxVolume * 0.1, now); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now); + oscillator.stop(now + duration); + }; +} + +function createClickSound() { + return () => { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 0.15; + + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(400, now); + oscillator.frequency.exponentialRampToValueAtTime(200, now + duration); + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(AUDIO_CONFIG.sfxVolume * 0.2, now); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now); + oscillator.stop(now + duration); + }; +} + +function createDoorSound() { + return () => { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 1.5; + + // Create creaking sound with multiple oscillators + const frequencies = [120, 180, 240]; + + frequencies.forEach((freq, index) => { + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sawtooth'; + oscillator.frequency.setValueAtTime(freq, now); + oscillator.frequency.linearRampToValueAtTime(freq * 0.9, now + duration); + + const gainNode = audioContext.createGain(); + const volume = AUDIO_CONFIG.sfxVolume * 0.15 * (1 - index * 0.2); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(volume, now + 0.1); + gainNode.gain.linearRampToValueAtTime(volume * 0.7, now + duration * 0.5); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + const filter = audioContext.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 800; + + oscillator.connect(filter); + filter.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now + index * 0.1); + oscillator.stop(now + duration); + }); + }; +} + +function createEnterSound() { + return () => { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 2; + + // Deep resonant sound + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(80, now); + oscillator.frequency.exponentialRampToValueAtTime(60, now + duration); + + const oscillator2 = audioContext.createOscillator(); + oscillator2.type = 'sine'; + oscillator2.frequency.setValueAtTime(120, now); + oscillator2.frequency.exponentialRampToValueAtTime(90, now + duration); + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(AUDIO_CONFIG.sfxVolume * 0.3, now + 0.5); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + oscillator.connect(gainNode); + oscillator2.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now); + oscillator2.start(now); + oscillator.stop(now + duration); + oscillator2.stop(now + duration); + }; +} + +function createRotateSound() { + return () => { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 2; + + // Mechanical rotation sound + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sawtooth'; + oscillator.frequency.setValueAtTime(100, now); + oscillator.frequency.linearRampToValueAtTime(150, now + duration * 0.5); + oscillator.frequency.linearRampToValueAtTime(100, now + duration); + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(AUDIO_CONFIG.sfxVolume * 0.2, now); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + const filter = audioContext.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 500; + + oscillator.connect(filter); + filter.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now); + oscillator.stop(now + duration); + }; +} + +function startAmbience() { + if (!audioContext || ambienceLoop) return; + + createAmbienceLoop(); +} + +function createAmbienceLoop() { + const duration = 8; + const now = audioContext.currentTime; + + // Wind sound - brown noise filtered + const bufferSize = audioContext.sampleRate * duration; + const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate); + const data = buffer.getChannelData(0); + + let lastOut = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + data[i] = (lastOut + (0.02 * white)) / 1.02; + lastOut = data[i]; + data[i] *= 0.5; + } + + const noise = audioContext.createBufferSource(); + noise.buffer = buffer; + noise.loop = true; + + const filter = audioContext.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 300; + filter.Q.value = 1; + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(AUDIO_CONFIG.ambienceVolume, now + 2); + + noise.connect(filter); + filter.connect(gainNode); + gainNode.connect(audioContext.destination); + + noise.start(now); + ambienceLoop = { source: noise, gain: gainNode }; + + // Add occasional whispers + scheduleWhisper(); +} + +function scheduleWhisper() { + if (!audioContext) return; + + const delay = 5000 + Math.random() * 10000; + + setTimeout(() => { + playWhisper(); + scheduleWhisper(); + }, delay); +} + +function playWhisper() { + if (!audioContext) return; + + const now = audioContext.currentTime; + const duration = 2; + + // Create eerie whisper-like sound + const oscillator = audioContext.createOscillator(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(200 + Math.random() * 100, now); + oscillator.frequency.linearRampToValueAtTime(250 + Math.random() * 100, now + duration); + + const lfo = audioContext.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 5 + Math.random() * 3; + + const lfoGain = audioContext.createGain(); + lfoGain.gain.value = 50; + + lfo.connect(lfoGain); + lfoGain.connect(oscillator.frequency); + + const gainNode = audioContext.createGain(); + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(AUDIO_CONFIG.ambienceVolume * 0.3, now + 0.5); + gainNode.gain.linearRampToValueAtTime(AUDIO_CONFIG.ambienceVolume * 0.2, now + duration * 0.7); + gainNode.gain.exponentialRampToValueAtTime(0.01, now + duration); + + const filter = audioContext.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = 1000; + filter.Q.value = 5; + + oscillator.connect(filter); + filter.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(now); + lfo.start(now); + oscillator.stop(now + duration); + lfo.stop(now + duration); +} + +function stopAmbience() { + if (ambienceLoop) { + const now = audioContext.currentTime; + ambienceLoop.gain.gain.exponentialRampToValueAtTime(0.01, now + 1); + setTimeout(() => { + if (ambienceLoop) { + ambienceLoop.source.stop(); + ambienceLoop = null; + } + }, 1000); + } +} + +function playSound(soundName) { + if (!isAudioInitialized) { + initAudio(); + } + + if (sounds[soundName]) { + sounds[soundName](); + } +} + +function setMasterVolume(volume) { + AUDIO_CONFIG.masterVolume = Math.max(0, Math.min(1, volume)); +} + +function setAmbienceVolume(volume) { + AUDIO_CONFIG.ambienceVolume = Math.max(0, Math.min(1, volume)); + if (ambienceLoop) { + ambienceLoop.gain.gain.value = AUDIO_CONFIG.ambienceVolume; + } +} + +function setSFXVolume(volume) { + AUDIO_CONFIG.sfxVolume = Math.max(0, Math.min(1, volume)); +} + +// Initialize on first user interaction +document.addEventListener('click', () => { + if (!isAudioInitialized) { + initAudio(); + } +}, { once: true }); + +// Export API +window.audioAPI = { + init: initAudio, + playSound: playSound, + startAmbience: startAmbience, + stopAmbience: stopAmbience, + setMasterVolume: setMasterVolume, + setAmbienceVolume: setAmbienceVolume, + setSFXVolume: setSFXVolume +}; diff --git a/public/index.html b/public/index.html index 58a91355d..541ea8b5e 100644 --- a/public/index.html +++ b/public/index.html @@ -1,33 +1,195 @@ - - -
- -Where darkness drips in luxury
+Explore the gothic estate
+Click to rotate and discover
+Enter the world within
+Loading Mortéz World...