Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Marimba - Interactive Musical Instrument</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| overflow: hidden; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| position: relative; | |
| } | |
| #canvas { | |
| display: block; | |
| cursor: grab; | |
| } | |
| #canvas:active { | |
| cursor: grabbing; | |
| } | |
| .ui-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| padding: 20px; | |
| background: linear-gradient(180deg, rgba(0,0,0,0.5) 0%, transparent 100%); | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| color: white; | |
| pointer-events: auto; | |
| } | |
| .title { | |
| font-size: 28px; | |
| font-weight: bold; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| } | |
| .title h1 { | |
| font-size: 32px; | |
| background: linear-gradient(45deg, #fff, #ffd700); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 15px; | |
| flex-wrap: wrap; | |
| } | |
| .control-btn { | |
| padding: 10px 20px; | |
| background: rgba(255,255,255,0.2); | |
| backdrop-filter: blur(10px); | |
| border: 2px solid rgba(255,255,255,0.3); | |
| border-radius: 50px; | |
| color: white; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| pointer-events: auto; | |
| } | |
| .control-btn:hover { | |
| background: rgba(255,255,255,0.3); | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(0,0,0,0.2); | |
| } | |
| .control-btn.active { | |
| background: linear-gradient(45deg, #667eea, #764ba2); | |
| border-color: transparent; | |
| } | |
| .info-panel { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 20px; | |
| background: rgba(0,0,0,0.7); | |
| backdrop-filter: blur(10px); | |
| padding: 20px; | |
| border-radius: 15px; | |
| color: white; | |
| max-width: 300px; | |
| pointer-events: auto; | |
| } | |
| .info-panel h3 { | |
| margin-bottom: 10px; | |
| color: #ffd700; | |
| } | |
| .info-panel p { | |
| margin: 5px 0; | |
| font-size: 14px; | |
| opacity: 0.9; | |
| } | |
| .note-display { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| font-size: 72px; | |
| font-weight: bold; | |
| color: white; | |
| text-shadow: 3px 3px 6px rgba(0,0,0,0.5); | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.2s ease; | |
| z-index: 20; | |
| } | |
| .note-display.show { | |
| animation: notePop 0.8s ease; | |
| } | |
| @keyframes notePop { | |
| 0% { | |
| opacity: 0; | |
| transform: translate(-50%, -50%) scale(0.5); | |
| } | |
| 50% { | |
| opacity: 1; | |
| transform: translate(-50%, -50%) scale(1.2); | |
| } | |
| 100% { | |
| opacity: 0; | |
| transform: translate(-50%, -50%) scale(1); | |
| } | |
| } | |
| .volume-control { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| background: rgba(0,0,0,0.7); | |
| backdrop-filter: blur(10px); | |
| padding: 15px; | |
| border-radius: 15px; | |
| color: white; | |
| pointer-events: auto; | |
| } | |
| .volume-slider { | |
| width: 150px; | |
| margin-top: 10px; | |
| } | |
| .loading { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 24px; | |
| font-weight: bold; | |
| text-align: center; | |
| } | |
| .loading-spinner { | |
| width: 50px; | |
| height: 50px; | |
| border: 3px solid rgba(255,255,255,0.3); | |
| border-top-color: white; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin: 20px auto; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| @media (max-width: 768px) { | |
| .title h1 { | |
| font-size: 24px; | |
| } | |
| .info-panel { | |
| display: none; | |
| } | |
| .controls { | |
| gap: 10px; | |
| } | |
| .control-btn { | |
| padding: 8px 15px; | |
| font-size: 14px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="loading" id="loading"> | |
| <div class="loading-spinner"></div> | |
| Loading 3D Marimba... | |
| </div> | |
| <div class="ui-overlay"> | |
| <div class="header"> | |
| <div class="title"> | |
| <h1>3D Marimba</h1> | |
| <span style="opacity: 0.8;">Interactive Instrument</span> | |
| </div> | |
| <div class="controls"> | |
| <button class="control-btn" id="rotateBtn">Auto Rotate</button> | |
| <button class="control-btn" id="resetBtn">Reset View</button> | |
| <button class="control-btn active" id="soundBtn">Sound On</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="info-panel"> | |
| <h3>How to Play</h3> | |
| <p>🎵 Click on bars to play notes</p> | |
| <p>🖱️ Drag to rotate the view</p> | |
| <p>🔍 Scroll to zoom in/out</p> | |
| <p>⌨️ Press keys A-L for quick play</p> | |
| </div> | |
| <div class="volume-control"> | |
| <label for="volume">Volume</label> | |
| <input type="range" id="volume" class="volume-slider" min="0" max="100" value="70"> | |
| </div> | |
| <div class="note-display" id="noteDisplay"></div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| // Global variables | |
| let scene, camera, renderer, raycaster, mouse; | |
| let marimbaBars = []; | |
| let mallets = []; | |
| let audioContext; | |
| let masterGainNode; | |
| let autoRotate = false; | |
| let soundEnabled = true; | |
| let isDragging = false; | |
| let previousMousePosition = { x: 0, y: 0 }; | |
| // Musical notes frequencies (pentatonic scale for pleasant sound) | |
| const notes = [ | |
| { note: 'C', freq: 261.63, color: '#FF6B6B' }, | |
| { note: 'D', freq: 293.66, color: '#4ECDC4' }, | |
| { note: 'E', freq: 329.63, color: '#45B7D1' }, | |
| { note: 'G', freq: 392.00, color: '#96CEB4' }, | |
| { note: 'A', freq: 440.00, color: '#FFEAA7' }, | |
| { note: 'C', freq: 523.25, color: '#DFE6E9' }, | |
| { note: 'D', freq: 587.33, color: '#74B9FF' }, | |
| { note: 'E', freq: 659.25, color: '#A29BFE' }, | |
| { note: 'G', freq: 783.99, color: '#FD79A8' }, | |
| { note: 'A', freq: 880.00, color: '#FDCB6E' }, | |
| { note: 'C', freq: 1046.50, color: '#6C5CE7' }, | |
| { note: 'D', freq: 1174.66, color: '#00B894' } | |
| ]; | |
| // Initialize Three.js | |
| function init() { | |
| // Scene setup | |
| scene = new THREE.Scene(); | |
| scene.fog = new THREE.Fog(0x764ba2, 10, 50); | |
| // Camera setup | |
| camera = new THREE.PerspectiveCamera( | |
| 75, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000 | |
| ); | |
| camera.position.set(0, 5, 12); | |
| camera.lookAt(0, 0, 0); | |
| // Renderer setup | |
| renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.2; | |
| document.body.appendChild(renderer.domElement); | |
| // Raycaster for mouse interaction | |
| raycaster = new THREE.Raycaster(); | |
| mouse = new THREE.Vector2(); | |
| // Lighting | |
| setupLighting(); | |
| // Create marimba | |
| createMarimba(); | |
| // Create mallets | |
| createMallets(); | |
| // Event listeners | |
| setupEventListeners(); | |
| // Initialize audio | |
| initAudio(); | |
| // Hide loading | |
| document.getElementById('loading').style.display = 'none'; | |
| // Start animation loop | |
| animate(); | |
| } | |
| function setupLighting() { | |
| // Ambient light | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); | |
| scene.add(ambientLight); | |
| // Main directional light | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1); | |
| directionalLight.position.set(5, 10, 5); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.camera.near = 0.1; | |
| directionalLight.shadow.camera.far = 50; | |
| directionalLight.shadow.camera.left = -15; | |
| directionalLight.shadow.camera.right = 15; | |
| directionalLight.shadow.camera.top = 15; | |
| directionalLight.shadow.camera.bottom = -15; | |
| directionalLight.shadow.mapSize.width = 2048; | |
| directionalLight.shadow.mapSize.height = 2048; | |
| scene.add(directionalLight); | |
| // Point lights for ambiance | |
| const pointLight1 = new THREE.PointLight(0xff6b6b, 0.5, 10); | |
| pointLight1.position.set(-5, 3, 3); | |
| scene.add(pointLight1); | |
| const pointLight2 = new THREE.PointLight(0x4ecdc4, 0.5, 10); | |
| pointLight2.position.set(5, 3, 3); | |
| scene.add(pointLight2); | |
| } | |
| function createMarimba() { | |
| const barGroup = new THREE.Group(); | |
| // Create resonator box | |
| const resonatorGeometry = new THREE.BoxGeometry(14, 1, 4); | |
| const resonatorMaterial = new THREE.MeshPhongMaterial({ | |
| color: 0x8B4513, | |
| shininess: 30 | |
| }); | |
| const resonator = new THREE.Mesh(resonatorGeometry, resonatorMaterial); | |
| resonator.position.y = -0.5; | |
| resonator.receiveShadow = true; | |
| barGroup.add(resonator); | |
| // Create bars | |
| const barWidth = 1; | |
| const barDepth = 0.3; | |
| const barSpacing = 0.1; | |
| const totalWidth = notes.length * barWidth + (notes.length - 1) * barSpacing; | |
| notes.forEach((note, index) => { | |
| // Calculate bar dimensions (higher notes are shorter) | |
| const barHeight = 0.2; | |
| const barLength = 8 - (index * 0.3); | |
| // Create bar geometry | |
| const barGeometry = new THREE.BoxGeometry(barWidth, barHeight, barLength); | |
| // Create gradient material for each bar | |
| const barMaterial = new THREE.MeshPhongMaterial({ | |
| color: 0xD2691E, | |
| emissive: note.color, | |
| emissiveIntensity: 0, | |
| shininess: 100, | |
| specular: 0x222222 | |
| }); | |
| const bar = new THREE.Mesh(barGeometry, barMaterial); | |
| // Position bars | |
| const xPosition = (index - notes.length / 2 + 0.5) * (barWidth + barSpacing); | |
| bar.position.set(xPosition, 0.5, 0); | |
| bar.castShadow = true; | |
| bar.receiveShadow = true; | |
| // Store note data | |
| bar.userData = { | |
| note: note.note, | |
| frequency: note.freq, | |
| index: index, | |
| originalColor: 0xD2691E, | |
| hitColor: note.color | |
| }; | |
| marimbaBars.push(bar); | |
| barGroup.add(bar); | |
| }); | |
| scene.add(barGroup); | |
| } | |
| function createMallets() { | |
| const malletGroup = new THREE.Group(); | |
| // Create two mallets | |
| for (let i = 0; i < 2; i++) { | |
| // Handle | |
| const handleGeometry = new THREE.CylinderGeometry(0.05, 0.05, 2, 8); | |
| const handleMaterial = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); | |
| const handle = new THREE.Mesh(handleGeometry, handleMaterial); | |
| // Head | |
| const headGeometry = new THREE.SphereGeometry(0.3, 16, 16); | |
| const headMaterial = new THREE.MeshPhongMaterial({ | |
| color: i === 0 ? 0xFF0000 : 0x0000FF, | |
| shininess: 100 | |
| }); | |
| const head = new THREE.Mesh(headGeometry, headMaterial); | |
| head.position.y = 1; | |
| const mallet = new THREE.Group(); | |
| mallet.add(handle); | |
| mallet.add(head); | |
| mallet.position.set(i === 0 ? -3 : 3, 3, 5); | |
| mallet.rotation.z = i === 0 ? -0.5 : 0.5; | |
| mallets.push(mallet); | |
| malletGroup.add(mallet); | |
| } | |
| scene.add(malletGroup); | |
| } | |
| function initAudio() { | |
| // Create audio context | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| // Create master gain node | |
| masterGainNode = audioContext.createGain(); | |
| masterGainNode.connect(audioContext.destination); | |
| masterGainNode.gain.value = 0.7; | |
| } | |
| function playNote(frequency, bar) { | |
| if (!soundEnabled) return; | |
| // Create oscillator | |
| const oscillator = audioContext.createOscillator(); | |
| const gainNode = audioContext.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(masterGainNode); | |
| // Set frequency and waveform | |
| oscillator.frequency.value = frequency; | |
| oscillator.type = 'sine'; | |
| // ADSR envelope | |
| const now = audioContext.currentTime; | |
| gainNode.gain.setValueAtTime(0, now); | |
| gainNode.gain.linearRampToValueAtTime(0.3, now + 0.01); // Attack | |
| gainNode.gain.exponentialRampToValueAtTime(0.2, now + 0.1); // Decay/Sustain | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, now + 1.5); // Release | |
| // Start and stop | |
| oscillator.start(now); | |
| oscillator.stop(now + 1.5); | |
| // Add harmonics for richer sound | |
| const harmonic = audioContext.createOscillator(); | |
| const harmonicGain = audioContext.createGain(); | |
| harmonic.connect(harmonicGain); | |
| harmonicGain.connect(masterGainNode); | |
| harmonic.frequency.value = frequency * 2; | |
| harmonic.type = 'triangle'; | |
| harmonicGain.gain.setValueAtTime(0, now); | |
| harmonicGain.gain.linearRampToValueAtTime(0.1, now + 0.01); | |
| harmonicGain.gain.exponentialRampToValueAtTime(0.01, now + 1); | |
| harmonic.start(now); | |
| harmonic.stop(now + 1); | |
| // Visual feedback | |
| if (bar) { | |
| animateBarHit(bar); | |
| } | |
| // Show note display | |
| showNoteDisplay(bar.userData.note); | |
| } | |
| function animateBarHit(bar) { | |
| // Animate bar color | |
| bar.material.emissiveIntensity = 0.5; | |
| // Animate bar movement | |
| const originalY = bar.position.y; | |
| let animationTime = 0; | |
| const animateHit = () => { | |
| animationTime += 0.05; | |
| if (animationTime < 0.2) { | |
| // Move down | |
| bar.position.y = originalY - Math.sin(animationTime * Math.PI / 0.2) * 0.1; | |
| } else if (animationTime < 0.4) { | |
| // Move back up | |
| bar.position.y = originalY - Math.sin((animationTime - 0.2) * Math.PI / 0.2) * 0.05; | |
| } else { | |
| bar.position.y = originalY; | |
| bar.material.emissiveIntensity = 0; | |
| return; | |
| } | |
| requestAnimationFrame(animateHit); | |
| }; | |
| animateHit(); | |
| // Animate mallet | |
| const mallet = mallets[bar.userData.index % 2]; | |
| animateMalletHit(mallet, bar.position); | |
| } | |
| function animateMalletHit(mallet, targetPosition) { | |
| const originalPosition = mallet.position.clone(); | |
| const originalRotation = mallet.rotation.clone(); | |
| let animationTime = 0; | |
| const animateMallet = () => { | |
| animationTime += 0.05; | |
| if (animationTime < 0.3) { | |
| // Move towards target | |
| const t = animationTime / 0.3; | |
| mallet.position.lerpVectors(originalPosition, targetPosition.clone().add(new THREE.Vector3(0, 1, 0)), t); | |
| mallet.rotation.z = originalRotation.z + Math.sin(t * Math.PI) * 0.5; | |
| } else if (animationTime < 0.6) { | |
| // Return to original position | |
| const t = (animationTime - 0.3) / 0.3; | |
| mallet.position.lerpVectors(targetPosition.clone().add(new THREE.Vector3(0, 1, 0)), originalPosition, t); | |
| mallet.rotation.z = originalRotation.z + Math.sin((1 - t) * Math.PI) * 0.5; | |
| } else { | |
| mallet.position.copy(originalPosition); | |
| mallet.rotation.copy(originalRotation); | |
| return; | |
| } | |
| requestAnimationFrame(animateMallet); | |
| }; | |
| animateMallet(); | |
| } | |
| function showNoteDisplay(note) { | |
| const display = document.getElementById('noteDisplay'); | |
| display.textContent = note; | |
| display.classList.add('show'); | |
| setTimeout(() => { | |
| display.classList.remove('show'); | |
| }, 800); | |
| } | |
| function setupEventListeners() { | |
| // Mouse events | |
| renderer.domElement.addEventListener('mousedown', onMouseDown); | |
| renderer.domElement.addEventListener('mousemove', onMouseMove); | |
| renderer.domElement.addEventListener('mouseup', onMouseUp); | |
| renderer.domElement.addEventListener('click', onMouseClick); | |
| renderer.domElement.addEventListener('wheel', onMouseWheel); | |
| // Touch events for mobile | |
| renderer.domElement.addEventListener('touchstart', onTouchStart); | |
| renderer.domElement.addEventListener('touchmove', onTouchMove); | |
| renderer.domElement.addEventListener('touchend', onTouchEnd); | |
| // Keyboard events | |
| window.addEventListener('keydown', onKeyDown); | |
| // UI controls | |
| document.getElementById('rotateBtn').addEventListener('click', toggleAutoRotate); | |
| document.getElementById('resetBtn').addEventListener('click', resetView); | |
| document.getElementById('soundBtn').addEventListener('click', toggleSound); | |
| document.getElementById('volume').addEventListener('input', updateVolume); | |
| // Window resize | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| function onMouseDown(event) { | |
| isDragging = true; | |
| previousMousePosition = { | |
| x: event.clientX, | |
| y: event.clientY | |
| }; | |
| } | |
| function onMouseMove(event) { | |
| if (!isDragging) return; | |
| const deltaMove = { | |
| x: event.clientX - previousMousePosition.x, | |
| y: event.clientY - previousMousePosition.y | |
| }; | |
| // Rotate camera around the marimba | |
| const spherical = new THREE.Spherical(); | |
| spherical.setFromVector3(camera.position); | |
| spherical.theta -= deltaMove.x * 0.01; | |
| spherical.phi += deltaMove.y * 0.01; | |
| spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); | |
| camera.position.setFromSpherical(spherical); | |
| camera.lookAt(0, 0, 0); | |
| previousMousePosition = { | |
| x: event.clientX, | |
| y: event.clientY | |
| }; | |
| } | |
| function onMouseUp() { | |
| isDragging = false; | |
| } | |
| function onMouseClick(event) { | |
| if (isDragging) return; | |
| // Calculate mouse position in normalized device coordinates | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| // Update the picking ray with the camera and mouse position | |
| raycaster.setFromCamera(mouse, camera); | |
| // Calculate objects intersecting the picking ray | |
| const intersects = raycaster.intersectObjects(marimbaBars); | |
| if (intersects.length > 0) { | |
| const bar = intersects[0].object; | |
| playNote(bar.userData.frequency, bar); | |
| } | |
| } | |
| function onMouseWheel(event) { | |
| event.preventDefault(); | |
| const zoomSpeed = 0.1; | |
| const direction = event.deltaY > 0 ? 1 : -1; | |
| camera.position.multiplyScalar(1 + direction * zoomSpeed); | |
| // Limit zoom | |
| const distance = camera.position.length(); | |
| if (distance < 5) { | |
| camera.position.normalize().multiplyScalar(5); | |
| } else if (distance > 30) { | |
| camera.position.normalize().multiplyScalar(30); | |
| } | |
| } | |
| function onTouchStart(event) { | |
| if (event.touches.length === 1) { | |
| isDragging = true; | |
| previousMousePosition = { | |
| x: event.touches[0].clientX, | |
| y: event.touches[0].clientY | |
| }; | |
| } | |
| } | |
| function onTouchMove(event) { | |
| if (event.touches.length === 1 && isDragging) { | |
| const deltaMove = { | |
| x: event.touches[0].clientX - previousMousePosition.x, | |
| y: event.touches[0].clientY - previousMousePosition.y | |
| }; | |
| const spherical = new THREE.Spherical(); | |
| spherical.setFromVector3(camera.position); | |
| spherical.theta -= deltaMove.x * 0.01; | |
| spherical.phi += deltaMove.y * 0.01; | |
| spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); | |
| camera.position.setFromSpherical(spherical); | |
| camera.lookAt(0, 0, 0); | |
| previousMousePosition = { | |
| x: event.touches[0].clientX, | |
| y: event.touches[0].clientY | |
| }; | |
| } | |
| } | |
| function onTouchEnd(event) { | |
| isDragging = false; | |
| // Handle tap for playing notes | |
| if (event.changedTouches.length === 1 && !isDragging) { | |
| const touch = event.changedTouches[0]; | |
| mouse.x = (touch.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(marimbaBars); | |
| if (intersects.length > 0) { | |
| const bar = intersects[0].object; | |
| playNote(bar.userData.frequency, bar); | |
| } | |
| } | |
| } | |
| function onKeyDown(event) { | |
| // Map keyboard keys to notes | |
| const keyMap = { | |
| 'a': 0, 's': 1, 'd': 2, 'f': 3, 'g': 4, 'h': 5, | |
| 'j': 6, 'k': 7, 'l': 8, ';': 9, "'": 10, 'Enter': 11 | |
| }; | |
| const key = event.key.toLowerCase(); | |
| if (keyMap.hasOwnProperty(key) && marimbaBars[keyMap[key]]) { | |
| const bar = marimbaBars[keyMap[key]]; | |
| playNote(bar.userData.frequency, bar); | |
| } | |
| } | |
| function toggleAutoRotate() { | |
| autoRotate = !autoRotate; | |
| const btn = document.getElementById('rotateBtn'); | |
| btn.classList.toggle('active', autoRotate); | |
| } | |
| function resetView() { | |
| camera.position.set(0, 5, 12); | |
| camera.lookAt(0, 0, 0); | |
| } | |
| function toggleSound() { | |
| soundEnabled = !soundEnabled; | |
| const btn = document.getElementById('soundBtn'); | |
| btn.classList.toggle('active', soundEnabled); | |
| btn.textContent = soundEnabled ? 'Sound On' : 'Sound Off'; | |
| } | |
| function updateVolume(event) { | |
| const volume = event.target.value / 100; | |
| masterGainNode.gain.value = volume; | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Auto rotation | |
| if (autoRotate && !isDragging) { | |
| const time = Date.now() * 0.0005; | |
| camera.position.x = Math.sin(time) * 12; | |
| camera.position.z = Math.cos(time) * 12; | |
| camera.lookAt(0, 0, 0); | |
| } | |
| // Animate mallets slightly | |
| mallets.forEach((mallet, index) => { | |
| mallet.rotation.y = Math.sin(Date.now() * 0.001 + index) * 0.1; | |
| }); | |
| renderer.render(scene, camera); | |
| } | |
| // Initialize the application | |
| window.addEventListener('load', init); | |
| </script> | |
| </body> | |
| </html> |