Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Hand Gesture Particle System</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #050505; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| user-select: none; | |
| } | |
| #canvas-container { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100vw; | |
| height: 100vh; | |
| z-index: 15; /* Higher than camera-wrapper */ | |
| pointer-events: none; | |
| } | |
| /* COMPACT UI */ | |
| #ui-layer { | |
| position: absolute; | |
| top: 15px; | |
| left: 15px; | |
| z-index: 20; | |
| color: #ff69b4; | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 12px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 105, 180, 0.2); | |
| font-size: 12px; | |
| line-height: 1.4; | |
| width: 210px; | |
| backdrop-filter: blur(6px); | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.4); | |
| pointer-events: auto; | |
| transition: height 0.3s ease; | |
| } | |
| #ui-layer.collapsed { | |
| width: auto; | |
| min-width: 120px; | |
| } | |
| #ui-layer.collapsed #ui-content { | |
| display: none; | |
| } | |
| .ui-header-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid rgba(255,255,255,0.1); | |
| padding-bottom: 4px; | |
| margin-bottom: 8px; | |
| } | |
| #ui-layer h1 { | |
| margin: 0; | |
| font-size: 14px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: #fff; | |
| pointer-events: none; | |
| } | |
| #btn-collapse { | |
| background: none; | |
| border: none; | |
| color: #ff69b4; | |
| font-weight: bold; | |
| font-size: 16px; | |
| cursor: pointer; | |
| padding: 0 5px; | |
| line-height: 1; | |
| } | |
| #btn-collapse:hover { | |
| color: #fff; | |
| } | |
| .stat-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| pointer-events: none; | |
| } | |
| .value { | |
| font-weight: bold; | |
| color: #fff; | |
| } | |
| /* Voice Toggle Button */ | |
| #btn-voice { | |
| width: 100%; | |
| margin-top: 5px; | |
| margin-bottom: 8px; | |
| padding: 8px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border: 1px solid rgba(255, 105, 180, 0.3); | |
| border-radius: 6px; | |
| color: #ddd; | |
| cursor: pointer; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| } | |
| #btn-voice:hover { | |
| background: rgba(255, 105, 180, 0.2); | |
| color: #fff; | |
| } | |
| #btn-voice.active { | |
| background: rgba(0, 255, 255, 0.2); | |
| color: #0ff; | |
| border-color: #0ff; | |
| box-shadow: 0 0 10px rgba(0, 255, 255, 0.3); | |
| } | |
| /* Draggable & Resizable Camera Wrapper */ | |
| #camera-wrapper { | |
| position: absolute; | |
| bottom: 15px; | |
| right: 15px; | |
| width: 200px; | |
| height: 150px; | |
| z-index: 18; /* Higher than canvas-container (15) */ | |
| background: rgba(0,0,0,0.5); | |
| border: 1px solid #444; | |
| border-radius: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| resize: both; /* Enable resizing */ | |
| overflow: hidden; /* Required for resize handle */ | |
| min-width: 100px; | |
| min-height: 80px; | |
| box-shadow: 0 0 10px rgba(0,0,0,0.5); | |
| pointer-events: auto; | |
| } | |
| #camera-handle { | |
| background: rgba(255, 105, 180, 0.2); | |
| color: #ccc; | |
| font-size: 10px; | |
| text-align: center; | |
| cursor: grab; | |
| padding: 2px 0; | |
| user-select: none; | |
| flex-shrink: 0; | |
| } | |
| #camera-handle:active { | |
| cursor: grabbing; | |
| background: rgba(255, 105, 180, 0.4); | |
| } | |
| #webcam-preview { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transform: scaleX(-1); | |
| opacity: 0.8; | |
| pointer-events: none; /* Let events pass to wrapper for resize */ | |
| } | |
| /* Fullscreen override for AR mode */ | |
| #camera-wrapper.fullscreen { | |
| width: 100vw ; | |
| height: 100vh ; | |
| top: 0 ; | |
| left: 0 ; | |
| right: auto ; | |
| bottom: auto ; | |
| border: none; | |
| border-radius: 0; | |
| resize: none; | |
| background: black; | |
| z-index: 1 ; /* Move BEHIND canvas-container (15) in AR mode */ | |
| pointer-events: none; /* Let clicks pass through to canvas if needed */ | |
| } | |
| #camera-wrapper.fullscreen #camera-handle { | |
| display: none; | |
| } | |
| #camera-wrapper.fullscreen #webcam-preview { | |
| opacity: 1.0; | |
| } | |
| #loading { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| z-index: 30; | |
| font-size: 18px; | |
| text-align: center; | |
| background: rgba(0,0,0,0.8); | |
| padding: 20px; | |
| border-radius: 12px; | |
| pointer-events: none; | |
| } | |
| #voice-indicator { | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background-color: #555; | |
| transition: all 0.2s; | |
| } | |
| #voice-indicator.listening { | |
| background-color: #00ff00; | |
| box-shadow: 0 0 6px #00ff00; | |
| } | |
| </style> | |
| <!-- Three.js --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://unpkg.com/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <!-- MediaPipe Hands --> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script> | |
| <style> | |
| /* Fix for GitHub Pages 404s */ | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loading">Starting System...<br><span style="font-size:14px; color:#aaa;">Allow Camera & Mic</span></div> | |
| <div id="ui-layer"> | |
| <div class="ui-header-row"> | |
| <h1>Controls</h1> | |
| <button id="btn-collapse">−</button> | |
| </div> | |
| <div id="ui-content"> | |
| <button id="btn-voice"> | |
| <div id="voice-indicator"></div> | |
| <span id="voice-btn-text">Enable Voice Mode</span> | |
| </button> | |
| <div class="stat-row"> | |
| <span>Gesture/Word:</span> | |
| <span id="gesture-val" class="value">...</span> | |
| </div> | |
| <div class="stat-row"> | |
| <span>Fingers:</span> | |
| <span id="finger-val" class="value">0</span> | |
| </div> | |
| <hr style="border: 0; border-top: 1px solid rgba(255,255,255,0.1); margin: 5px 0;"> | |
| <div style="font-size: 11px; color: #ddd; pointer-events: none;"> | |
| <b>Voice Mode ON:</b><br> | |
| 🎤 Say ANY word<br> | |
| 👋 Touch text to scatter<br> | |
| <br> | |
| <b>Voice Mode OFF:</b><br> | |
| ☝️ 1: "I"<br> | |
| ✌️ 2: "LOVE"<br> | |
| 🤟 3: "YOU"<br> | |
| ✋ 4: "I LOVE YOU"<br> | |
| ✊ Fist: Shrink | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Camera Wrapper for Drag & Resize --> | |
| <div id="camera-wrapper"> | |
| <div id="camera-handle">:: Drag to Move ::</div> | |
| <video id="webcam-preview" playsinline></video> | |
| </div> | |
| <div id="canvas-container"></div> | |
| <script> | |
| /** | |
| * CONFIGURATION | |
| */ | |
| const CONFIG = { | |
| particleCount: 40000, | |
| text1: "I", | |
| text2: "LOVE", | |
| text3: "YOU", | |
| text4: "I LOVE YOU", | |
| particleSize: 0.08, | |
| scatterRadius: 35, | |
| textScale: 0.055, | |
| camZ: 40, | |
| interactionRadius: 8.0, | |
| repulsionStrength: 8.0 | |
| }; | |
| /** | |
| * STATE MANAGEMENT | |
| */ | |
| const state = { | |
| targetGestureLabel: "Waiting...", | |
| currentWeights: [0, 0, 0, 0, 0], | |
| targetWeights: [0, 0, 0, 0, 0], | |
| spreadTarget: 1.0, | |
| currentSpread: 1.0, | |
| scatterScaleTarget: 1.0, | |
| currentScatterScale: 1.0, | |
| fingerCount: 0, | |
| galaxyEffectActive: false, | |
| voiceModeActive: false, | |
| wasVoiceModeActive: false, | |
| voiceEnabledByUser: false, | |
| // Rotation Logic | |
| handPositionRaw: { x: 0.5, y: 0.5 }, | |
| // Interaction Physics Logic | |
| handPositions: [], | |
| isHandDetected: false | |
| }; | |
| /** | |
| * UI INTERACTION LOGIC (Collapse & Drag) | |
| */ | |
| // 1. Collapse UI | |
| const btnCollapse = document.getElementById('btn-collapse'); | |
| const uiLayer = document.getElementById('ui-layer'); | |
| btnCollapse.addEventListener('click', () => { | |
| uiLayer.classList.toggle('collapsed'); | |
| btnCollapse.innerText = uiLayer.classList.contains('collapsed') ? '+' : '−'; | |
| }); | |
| // 2. Drag Camera | |
| const camWrapper = document.getElementById('camera-wrapper'); | |
| const camHandle = document.getElementById('camera-handle'); | |
| let isDragging = false; | |
| let dragOffset = { x: 0, y: 0 }; | |
| camHandle.addEventListener('mousedown', (e) => { | |
| if (state.voiceModeActive) return; // Disable drag in full screen | |
| isDragging = true; | |
| const rect = camWrapper.getBoundingClientRect(); | |
| dragOffset.x = e.clientX - rect.left; | |
| dragOffset.y = e.clientY - rect.top; | |
| // Switch from bottom/right positioning to top/left for dragging logic | |
| camWrapper.style.bottom = 'auto'; | |
| camWrapper.style.right = 'auto'; | |
| camWrapper.style.left = rect.left + 'px'; | |
| camWrapper.style.top = rect.top + 'px'; | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (isDragging) { | |
| camWrapper.style.left = (e.clientX - dragOffset.x) + 'px'; | |
| camWrapper.style.top = (e.clientY - dragOffset.y) + 'px'; | |
| } | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| isDragging = false; | |
| }); | |
| /** | |
| * THREE.JS SETUP | |
| */ | |
| const container = document.getElementById('canvas-container'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x050505); | |
| scene.fog = new THREE.FogExp2(0x050505, 0.012); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = CONFIG.camZ; | |
| const vFOV = THREE.MathUtils.degToRad(camera.fov); | |
| const heightAtZero = 2 * Math.tan(vFOV / 2) * camera.position.z; | |
| const widthAtZero = heightAtZero * camera.aspect; | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| container.appendChild(renderer.domElement); | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.autoRotate = false; | |
| controls.enableZoom = false; | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| /** | |
| * PARTICLE SYSTEM & TEXT GENERATION | |
| */ | |
| // Dynamic Text Generator (Reuse for Voice) | |
| function generateTextCoordinates(text, step = 2, scaleOverride = null) { | |
| console.log(`Generating text coords for: "${text}"`); | |
| const canvas = document.createElement('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const width = 3000; | |
| const height = 800; | |
| canvas.width = width; | |
| canvas.height = height; | |
| ctx.fillStyle = 'black'; | |
| ctx.fillRect(0, 0, width, height); | |
| ctx.fillStyle = 'white'; | |
| ctx.font = '900 250px Arial, sans-serif'; // Simplified font | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(text, width / 2, height / 2); | |
| const imageData = ctx.getImageData(0, 0, width, height); | |
| const data = imageData.data; | |
| const coords = []; | |
| const finalScale = scaleOverride || CONFIG.textScale; | |
| for (let y = 0; y < height; y += step) { | |
| for (let x = 0; x < width; x += step) { | |
| const i = (y * width + x) * 4; | |
| if (data[i] > 128) { | |
| const pX = (x - width / 2) * finalScale; | |
| const pY = -(y - height / 2) * finalScale; | |
| coords.push(new THREE.Vector3(pX, pY, 0)); | |
| } | |
| } | |
| } | |
| console.log(`Generated ${coords.length} points for "${text}"`); | |
| return coords; | |
| } | |
| const coordsText1 = generateTextCoordinates(CONFIG.text1, 3); | |
| const coordsText2 = generateTextCoordinates(CONFIG.text2, 3); | |
| const coordsText3 = generateTextCoordinates(CONFIG.text3, 3); | |
| const coordsText4 = generateTextCoordinates(CONFIG.text4, 3); | |
| let coordsText5 = generateTextCoordinates("HELLO", 2); | |
| const particleGeometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(CONFIG.particleCount * 3); | |
| const colors = new Float32Array(CONFIG.particleCount * 3); | |
| const posScatter = []; | |
| const posText1 = []; | |
| const posText2 = []; | |
| const posText3 = []; | |
| const posText4 = []; | |
| const posText5 = []; | |
| function fillPosArray(targetArray, sourceCoords) { | |
| targetArray.length = 0; | |
| const depth = 2.0; | |
| const noise = 0.2; | |
| for (let i = 0; i < CONFIG.particleCount; i++) { | |
| if (sourceCoords.length === 0) { | |
| targetArray.push(new THREE.Vector3(0,0,0)); | |
| continue; | |
| } | |
| const index = Math.floor(Math.random() * sourceCoords.length); | |
| const p = sourceCoords[index]; | |
| targetArray.push(new THREE.Vector3( | |
| p.x + (Math.random() - 0.5) * noise, | |
| p.y + (Math.random() - 0.5) * noise, | |
| p.z + (Math.random() - 0.5) * depth | |
| )); | |
| } | |
| } | |
| for (let i = 0; i < CONFIG.particleCount; i++) { | |
| const theta = Math.random() * Math.PI * 2; | |
| const phi = Math.acos(Math.random() * 2 - 1); | |
| const r = Math.cbrt(Math.random()) * CONFIG.scatterRadius; | |
| const x = r * Math.sin(phi) * Math.cos(theta); | |
| const y = r * Math.sin(phi) * Math.sin(theta); | |
| const z = r * Math.cos(phi); | |
| positions[i * 3] = x; | |
| positions[i * 3 + 1] = y; | |
| positions[i * 3 + 2] = z; | |
| colors[i * 3] = 1; colors[i * 3 + 1] = 1; colors[i * 3 + 2] = 1; | |
| posScatter.push(new THREE.Vector3(x, y, z)); | |
| } | |
| fillPosArray(posText1, coordsText1); | |
| fillPosArray(posText2, coordsText2); | |
| fillPosArray(posText3, coordsText3); | |
| fillPosArray(posText4, coordsText4); | |
| fillPosArray(posText5, coordsText5); | |
| particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| particleGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| const particleMaterial = new THREE.PointsMaterial({ | |
| vertexColors: true, | |
| size: CONFIG.particleSize, | |
| transparent: true, | |
| opacity: 0.9, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| sizeAttenuation: true | |
| }); | |
| const particleSystem = new THREE.Points(particleGeometry, particleMaterial); | |
| scene.add(particleSystem); | |
| function updateDynamicText(text) { | |
| const length = Math.max(text.length, 3); | |
| let optimalScale = 0.09; | |
| if (length > 4) { | |
| optimalScale = 0.4 / length; | |
| } | |
| optimalScale = Math.max(0.035, optimalScale); | |
| const newCoords = generateTextCoordinates(text, 2, optimalScale); | |
| fillPosArray(posText5, newCoords); | |
| } | |
| /** | |
| * SYSTEM 3: Galaxy Background | |
| */ | |
| const galaxyCount = 8000; | |
| const galaxyGeo = new THREE.BufferGeometry(); | |
| const galaxyPos = new Float32Array(galaxyCount * 3); | |
| const galaxyColors = new Float32Array(galaxyCount * 3); | |
| for(let i=0; i<galaxyCount; i++) { | |
| const r = 20 + Math.random() * 60; | |
| const theta = Math.random() * Math.PI * 2; | |
| const phi = Math.acos(Math.random() * 2 - 1); | |
| galaxyPos[i*3] = r * Math.sin(phi) * Math.cos(theta); | |
| galaxyPos[i*3+1] = r * Math.sin(phi) * Math.sin(theta); | |
| galaxyPos[i*3+2] = r * Math.cos(phi); | |
| const c = new THREE.Color(); | |
| const rand = Math.random(); | |
| if(rand < 0.3) c.setHex(0x00ffff); | |
| else if (rand < 0.6) c.setHex(0x0000ff); | |
| else if (rand < 0.8) c.setHex(0x4b0082); | |
| else c.setHex(0xffffff); | |
| c.multiplyScalar(0.5 + Math.random() * 0.5); | |
| galaxyColors[i*3] = c.r; | |
| galaxyColors[i*3+1] = c.g; | |
| galaxyColors[i*3+2] = c.b; | |
| } | |
| galaxyGeo.setAttribute('position', new THREE.BufferAttribute(galaxyPos, 3)); | |
| galaxyGeo.setAttribute('color', new THREE.BufferAttribute(galaxyColors, 3)); | |
| const galaxyMaterial = new THREE.PointsMaterial({ | |
| vertexColors: true, | |
| size: 0.04, | |
| transparent: true, | |
| opacity: 0, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const galaxySystem = new THREE.Points(galaxyGeo, galaxyMaterial); | |
| scene.add(galaxySystem); | |
| /** | |
| * VOICE RECOGNITION | |
| */ | |
| const voiceBtn = document.getElementById('btn-voice'); | |
| const voiceIndicator = document.getElementById('voice-indicator'); | |
| const voiceBtnText = document.getElementById('voice-btn-text'); | |
| let recognition = null; | |
| if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| recognition = new SpeechRecognition(); | |
| recognition.continuous = true; | |
| recognition.interimResults = false; | |
| recognition.lang = 'en-US'; | |
| recognition.onstart = () => { | |
| console.log("Voice recognition started"); | |
| voiceIndicator.classList.add('listening'); | |
| voiceBtnText.innerText = "Voice Mode: ON"; | |
| voiceBtn.classList.add('active'); | |
| }; | |
| recognition.onerror = (event) => { | |
| console.error("Voice recognition error", event.error); | |
| if (event.error === 'not-allowed') { | |
| alert("Microphone access denied. Please allow microphone access."); | |
| state.voiceEnabledByUser = false; | |
| } | |
| voiceIndicator.classList.remove('listening'); | |
| }; | |
| recognition.onend = () => { | |
| console.log("Voice recognition ended"); | |
| voiceIndicator.classList.remove('listening'); | |
| if (state.voiceEnabledByUser) { | |
| // Add a small delay before restarting to avoid rapid loops | |
| setTimeout(() => { | |
| try { recognition.start(); } catch(e) { console.warn("Restart failed", e); } | |
| }, 300); | |
| } else { | |
| voiceBtnText.innerText = "Enable Voice Mode"; | |
| voiceBtn.classList.remove('active'); | |
| } | |
| }; | |
| recognition.onresult = (event) => { | |
| if (!state.voiceEnabledByUser) return; | |
| const last = event.results.length - 1; | |
| const transcript = event.results[last][0].transcript.trim().toUpperCase(); | |
| const words = transcript.split(' '); | |
| const displayPhrase = words.slice(-3).join(' '); | |
| if (displayPhrase.length > 0) { | |
| updateDynamicText(displayPhrase); | |
| state.targetGestureLabel = "Voice: " + displayPhrase; | |
| state.targetWeights = [0, 0, 0, 0, 1]; | |
| state.spreadTarget = 0.0; | |
| } | |
| }; | |
| } else { | |
| voiceBtnText.innerText = "Voice Not Supported"; | |
| voiceBtn.disabled = true; | |
| } | |
| voiceBtn.addEventListener('click', () => { | |
| if (!recognition) return; | |
| state.voiceEnabledByUser = !state.voiceEnabledByUser; | |
| if (state.voiceEnabledByUser) { | |
| try { | |
| recognition.start(); | |
| state.voiceModeActive = true; | |
| state.targetGestureLabel = "Listening..."; | |
| state.spreadTarget = 0.0; | |
| state.targetWeights = [0, 0, 0, 0, 1]; | |
| updateDynamicText("HELLO"); | |
| } catch(e) { console.warn(e); } | |
| } else { | |
| recognition.stop(); | |
| state.voiceModeActive = false; | |
| state.targetGestureLabel = "Voice Mode OFF"; | |
| state.spreadTarget = 1.0; | |
| } | |
| }); | |
| /** | |
| * HAND TRACKING & INTERACTION | |
| */ | |
| const videoElement = document.getElementById('webcam-preview'); | |
| const uiGesture = document.getElementById('gesture-val'); | |
| const uiFingers = document.getElementById('finger-val'); | |
| const loading = document.getElementById('loading'); | |
| function onResults(results) { | |
| loading.style.display = 'none'; | |
| let detectedGesture = 0; | |
| let fCount = 0; | |
| let openness = 1.0; | |
| state.handPositions = []; | |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |
| state.isHandDetected = true; | |
| for (const landmarks of results.multiHandLandmarks) { | |
| const palmX = 1.0 - landmarks[9].x; | |
| const palmY = landmarks[9].y; | |
| const hPos = new THREE.Vector3( | |
| (palmX - 0.5) * widthAtZero, | |
| -(palmY - 0.5) * heightAtZero, | |
| 0 | |
| ); | |
| state.handPositions.push(hPos); | |
| } | |
| const landmarks = results.multiHandLandmarks[0]; | |
| const handedness = results.multiHandedness[0].label; | |
| const wrist = landmarks[0]; | |
| state.handPositionRaw.x = 1.0 - wrist.x; | |
| state.handPositionRaw.y = wrist.y; | |
| fCount = countFingers(landmarks, handedness); | |
| openness = getHandOpenness(landmarks); | |
| if (state.voiceEnabledByUser) { | |
| state.targetWeights = [0, 0, 0, 0, 1]; | |
| state.spreadTarget = 0.0; | |
| state.galaxyEffectActive = false; | |
| } else { | |
| if (fCount === 1) detectedGesture = 1; | |
| else if (fCount === 2) detectedGesture = 2; | |
| else if (fCount === 3) detectedGesture = 3; | |
| else if (fCount === 4) detectedGesture = 4; | |
| if (detectedGesture > 0) { | |
| state.targetGestureLabel = getLabel(detectedGesture); | |
| state.spreadTarget = 0.0; | |
| state.targetWeights = getWeights(detectedGesture); | |
| state.galaxyEffectActive = (detectedGesture === 4); | |
| } else { | |
| state.targetGestureLabel = fCount === 0 ? "Fist (Contract)" : "Scatter (Expand)"; | |
| state.spreadTarget = 1.0; | |
| state.galaxyEffectActive = false; | |
| const minScale = 0.1; | |
| const maxScale = 1.5; | |
| state.scatterScaleTarget = minScale + openness * (maxScale - minScale); | |
| } | |
| } | |
| state.fingerCount = fCount; | |
| } else { | |
| state.isHandDetected = false; | |
| if (state.voiceEnabledByUser) { | |
| state.spreadTarget = 0.0; | |
| state.targetWeights = [0, 0, 0, 0, 1]; | |
| state.handPositionRaw.x = 0.5; | |
| state.handPositionRaw.y = 0.5; | |
| } else { | |
| state.spreadTarget = 1.0; | |
| state.targetGestureLabel = "Waiting..."; | |
| state.scatterScaleTarget = 1.0; | |
| state.handPositionRaw.x = 0.5; | |
| state.handPositionRaw.y = 0.5; | |
| } | |
| state.fingerCount = 0; | |
| state.galaxyEffectActive = false; | |
| } | |
| updateUI(); | |
| } | |
| function getLabel(g) { | |
| if(g===1) return CONFIG.text1; | |
| if(g===2) return CONFIG.text2; | |
| if(g===3) return CONFIG.text3; | |
| if(g===4) return "I LOVE YOU"; | |
| return ""; | |
| } | |
| function getWeights(g) { | |
| if(g===1) return [1,0,0,0,0]; | |
| if(g===2) return [0,1,0,0,0]; | |
| if(g===3) return [0,0,1,0,0]; | |
| if(g===4) return [0,0,0,1,0]; | |
| return state.targetWeights; | |
| } | |
| function getHandOpenness(landmarks) { | |
| const wrist = landmarks[0]; | |
| const tips = [8, 12, 16, 20]; | |
| const mcps = [5, 9, 13, 17]; | |
| let distTips = 0, distMcps = 0; | |
| for(let i=0; i<4; i++){ | |
| distTips += Math.hypot(landmarks[tips[i]].x - wrist.x, landmarks[tips[i]].y - wrist.y); | |
| distMcps += Math.hypot(landmarks[mcps[i]].x - wrist.x, landmarks[mcps[i]].y - wrist.y); | |
| } | |
| const ratio = distTips / distMcps; | |
| return Math.max(0, Math.min(1, (ratio - 1.0) / 1.2)); | |
| } | |
| function countFingers(landmarks, handedness) { | |
| let count = 0; | |
| const fingerTips = [8, 12, 16, 20]; | |
| const fingerPips = [6, 10, 14, 18]; | |
| for (let i = 0; i < 4; i++) { | |
| if (landmarks[fingerTips[i]].y < landmarks[fingerPips[i]].y) count++; | |
| } | |
| const thumbTip = landmarks[4]; | |
| const thumbIp = landmarks[3]; | |
| const isRightHand = handedness === 'Right'; | |
| if (isRightHand) { | |
| if (thumbTip.x < thumbIp.x) count++; | |
| } else { | |
| if (thumbTip.x > thumbIp.x) count++; | |
| } | |
| return count; | |
| } | |
| function updateUI() { | |
| uiGesture.innerText = state.targetGestureLabel; | |
| uiFingers.innerText = state.fingerCount; | |
| } | |
| const hands = new Hands({locateFile: (file) => { | |
| return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; | |
| }}); | |
| hands.setOptions({ | |
| maxNumHands: 2, | |
| modelComplexity: 1, | |
| minDetectionConfidence: 0.5, | |
| minTrackingConfidence: 0.5 | |
| }); | |
| hands.onResults(onResults); | |
| const cameraFeed = new Camera(videoElement, { | |
| onFrame: async () => { | |
| await hands.send({image: videoElement}); | |
| }, | |
| width: 1280, | |
| height: 720 | |
| }); | |
| async function startCamera() { | |
| try { | |
| loading.innerHTML = "Requesting Camera Access..."; | |
| // Check if browser supports getUserMedia | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| throw new Error("Browser API 'navigator.mediaDevices.getUserMedia' not available"); | |
| } | |
| // Explicitly request permission first to debug | |
| const stream = await navigator.mediaDevices.getUserMedia({ video: true }); | |
| // Stop the stream immediately, we just wanted to check permission/availability | |
| stream.getTracks().forEach(track => track.stop()); | |
| loading.innerHTML = "Starting MediaPipe Camera..."; | |
| await cameraFeed.start(); | |
| } catch (err) { | |
| console.error("Camera Error:", err); | |
| loading.innerHTML = `Camera not found (${err.name}).<br>Switching to <b>Mouse Interaction Mode</b>.`; | |
| setTimeout(() => { | |
| loading.style.display = 'none'; | |
| activateMouseMode(); | |
| }, 2500); | |
| } | |
| } | |
| let isMouseMode = false; | |
| function activateMouseMode() { | |
| isMouseMode = true; | |
| state.isHandDetected = true; // Always "detect" hand in mouse mode | |
| // Keep camera wrapper visible but indicate no signal | |
| const camWrapper = document.getElementById('camera-wrapper'); | |
| camWrapper.style.display = 'flex'; // Ensure it's visible | |
| camWrapper.style.justifyContent = 'center'; | |
| camWrapper.style.alignItems = 'center'; | |
| camWrapper.style.background = 'rgba(20, 20, 20, 0.8)'; | |
| camWrapper.style.border = '1px solid #444'; | |
| const video = document.getElementById('webcam-preview'); | |
| video.style.opacity = '0.1'; // Dim the broken video element | |
| // Add a placeholder text if not exists | |
| if (!document.getElementById('no-cam-msg')) { | |
| const msg = document.createElement('div'); | |
| msg.id = 'no-cam-msg'; | |
| msg.innerHTML = "NO CAMERA<br>Mouse Mode"; | |
| msg.style.color = '#aaa'; | |
| msg.style.textAlign = 'center'; | |
| msg.style.fontSize = '12px'; | |
| msg.style.position = 'absolute'; | |
| camWrapper.appendChild(msg); | |
| } | |
| // Add mouse listener for interaction | |
| window.addEventListener('mousemove', (event) => { | |
| // Map mouse to 3D world coordinates on z=0 plane | |
| const vec = new THREE.Vector3(); | |
| const pos = new THREE.Vector3(); | |
| vec.set( | |
| (event.clientX / window.innerWidth) * 2 - 1, | |
| -(event.clientY / window.innerHeight) * 2 + 1, | |
| 0.5 | |
| ); | |
| vec.unproject(camera); | |
| vec.sub(camera.position).normalize(); | |
| const distance = -camera.position.z / vec.z; | |
| pos.copy(camera.position).add(vec.multiplyScalar(distance)); | |
| state.handPositions = [pos]; | |
| // Map mouse X to rotation | |
| state.handPositionRaw.x = event.clientX / window.innerWidth; | |
| state.handPositionRaw.y = event.clientY / window.innerHeight; | |
| }); | |
| // Add click listener to cycle gestures | |
| let gestureIndex = 0; | |
| window.addEventListener('click', () => { | |
| if (state.voiceModeActive) return; | |
| gestureIndex = (gestureIndex + 1) % 5; // 0 to 4 | |
| const g = gestureIndex; | |
| if (g === 0) { | |
| state.targetGestureLabel = "Mouse Click: Scatter"; | |
| state.spreadTarget = 1.0; | |
| state.galaxyEffectActive = false; | |
| } else { | |
| state.targetGestureLabel = "Mouse Click: " + getLabel(g); | |
| state.spreadTarget = 0.0; | |
| state.targetWeights = getWeights(g); | |
| state.galaxyEffectActive = (g === 4); | |
| } | |
| updateUI(); | |
| }); | |
| // Update UI instructions | |
| const uiContent = document.getElementById('ui-content'); | |
| const helpText = uiContent.querySelector('div[style*="font-size: 11px"]'); | |
| if(helpText) { | |
| helpText.innerHTML = ` | |
| <b>Mouse Mode Active:</b><br> | |
| 🖱️ Move mouse to interact<br> | |
| 🖱️ Click to cycle gestures<br> | |
| (I -> LOVE -> YOU -> ...)<br> | |
| <br> | |
| <b>Voice Mode:</b><br> | |
| 🎤 Still works if Mic is available | |
| `; | |
| } | |
| } | |
| startCamera(); | |
| /** | |
| * ANIMATION | |
| */ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Handle AR Mode Transition | |
| if (state.voiceModeActive !== state.wasVoiceModeActive) { | |
| if (state.voiceModeActive) { | |
| camWrapper.classList.add('fullscreen'); | |
| // Only make background transparent if camera is actually working/visible | |
| if (!isMouseMode) { | |
| scene.background = null; | |
| } else { | |
| scene.background = new THREE.Color(0x050505); | |
| } | |
| // Reset position when entering fullscreen to ensure it covers everything | |
| camWrapper.style.top = ''; | |
| camWrapper.style.left = ''; | |
| camWrapper.style.bottom = ''; | |
| camWrapper.style.right = ''; | |
| } else { | |
| camWrapper.classList.remove('fullscreen'); | |
| scene.background = new THREE.Color(0x050505); | |
| // Reset to default corner position or keep last dragged position? | |
| // Let's reset for safety or keep it if dragged. | |
| // Since we modified inline styles during drag, they persist. | |
| if (!camWrapper.style.top) { | |
| camWrapper.style.bottom = '15px'; | |
| camWrapper.style.right = '15px'; | |
| } | |
| } | |
| state.wasVoiceModeActive = state.voiceModeActive; | |
| } | |
| // Interpolators | |
| state.currentSpread += (state.spreadTarget - state.currentSpread) * 0.08; | |
| state.currentScatterScale += (state.scatterScaleTarget - state.currentScatterScale) * 0.1; | |
| for(let i=0; i<5; i++) { | |
| state.currentWeights[i] += (state.targetWeights[i] - state.currentWeights[i]) * 0.1; | |
| } | |
| // Hand Rotation | |
| const targetRotY = (state.handPositionRaw.x - 0.5) * 1.5; | |
| const targetRotX = (state.handPositionRaw.y - 0.5) * 1.5; | |
| particleSystem.rotation.y += (targetRotY - particleSystem.rotation.y) * 0.1; | |
| particleSystem.rotation.x += (targetRotX - particleSystem.rotation.x) * 0.1; | |
| // Rotate Galaxy (independent) | |
| galaxySystem.rotation.y += 0.002; | |
| galaxySystem.rotation.x += 0.001; | |
| const positionsArray = particleGeometry.attributes.position.array; | |
| const colorsArray = particleGeometry.attributes.color.array; | |
| const time = Date.now() * 0.001; | |
| const w1 = state.currentWeights[0]; | |
| const w2 = state.currentWeights[1]; | |
| const w3 = state.currentWeights[2]; | |
| const w4 = state.currentWeights[3]; | |
| const w5 = state.currentWeights[4]; | |
| const interactionRadSq = CONFIG.interactionRadius * CONFIG.interactionRadius; | |
| for (let i = 0; i < CONFIG.particleCount; i++) { | |
| // 1. Calculate Target | |
| const p1 = posText1[i]; | |
| const p2 = posText2[i]; | |
| const p3 = posText3[i]; | |
| const p4 = posText4[i]; | |
| const p5 = posText5[i]; // Dynamic Text | |
| const tx = p1.x*w1 + p2.x*w2 + p3.x*w3 + p4.x*w4 + p5.x*w5; | |
| const ty = p1.y*w1 + p2.y*w2 + p3.y*w3 + p4.y*w4 + p5.y*w5; | |
| const tz = p1.z*w1 + p2.z*w2 + p3.z*w3 + p4.z*w4 + p5.z*w5; | |
| // 2. Scatter | |
| const s = posScatter[i]; | |
| const sX = s.x * state.currentScatterScale; | |
| const sY = s.y * state.currentScatterScale; | |
| const sZ = s.z * state.currentScatterScale; | |
| const noiseScale = 0.5 * state.currentScatterScale; | |
| const nX = Math.sin(time + i*0.1) * noiseScale; | |
| const nY = Math.cos(time + i*0.13) * noiseScale; | |
| // 3. Blend | |
| let finalX = THREE.MathUtils.lerp(tx, sX + nX, state.currentSpread); | |
| let finalY = THREE.MathUtils.lerp(ty, sY + nY, state.currentSpread); | |
| let finalZ = THREE.MathUtils.lerp(tz, sZ, state.currentSpread); | |
| // 4. Physics Interaction (Multi-Hand) | |
| if (state.voiceModeActive && state.handPositions.length > 0) { | |
| for (const hPos of state.handPositions) { | |
| const dx = finalX - hPos.x; | |
| const dy = finalY - hPos.y; | |
| const dz = finalZ - hPos.z; | |
| const distSq = dx*dx + dy*dy + dz*dz; | |
| if (distSq < interactionRadSq) { | |
| const dist = Math.sqrt(distSq); | |
| const force = (CONFIG.interactionRadius - dist) / CONFIG.interactionRadius; | |
| // Stronger repulsion logic | |
| const repulsion = Math.pow(force, 2) * CONFIG.repulsionStrength; | |
| finalX += (dx / dist) * repulsion * 5; | |
| finalY += (dy / dist) * repulsion * 5; | |
| finalZ += (dz / dist) * repulsion * 5; | |
| } | |
| } | |
| } | |
| // Update Position | |
| const cx = positionsArray[i*3]; | |
| const cy = positionsArray[i*3+1]; | |
| const cz = positionsArray[i*3+2]; | |
| const speed = 0.1; | |
| positionsArray[i*3] += (finalX - cx) * speed; | |
| positionsArray[i*3+1] += (finalY - cy) * speed; | |
| positionsArray[i*3+2] += (finalZ - cz) * speed; | |
| // 5. Colors (UNIFIED & NEW AR COLOR) | |
| const baseColor = new THREE.Color(); | |
| const cPink1 = new THREE.Color(0xff69b4); // HotPink | |
| const cPurple = new THREE.Color(0xda70d6); // Orchid | |
| const cBlueNeon = new THREE.Color(0x00ffff); // Cyan for AR | |
| const cWhite = new THREE.Color(0xffffff); | |
| if (state.currentSpread > 0.8) { | |
| // Scatter Color | |
| const dist = Math.sqrt(finalX*finalX + finalY*finalY + finalZ*finalZ); | |
| const normDist = Math.min(dist / 40, 1); | |
| baseColor.setHSL(0.9, 0.5, 0.8 + normDist*0.2); | |
| } else { | |
| if (w5 > 0.5) { | |
| // AR / Voice Mode -> BLUE NEON / High Contrast | |
| baseColor.copy(cBlueNeon); | |
| // Make it brighter/more contrasty | |
| baseColor.offsetHSL(0, 0, 0.1); | |
| // Sparkle | |
| if (Math.random() > 0.92) baseColor.setHex(0xffffff); | |
| } else if (w1>0.5 || w2>0.5 || w3>0.5 || w4>0.5) { | |
| // All gestures now use the Unified Pink/Purple scheme (Love Theme) | |
| baseColor.lerpColors(cPink1, cPurple, 0.5); | |
| const hueOffset = (finalX / 60) * 0.1; | |
| baseColor.offsetHSL(hueOffset, 0, 0); | |
| } else { | |
| baseColor.setHex(0xffffff); | |
| } | |
| } | |
| colorsArray[i*3] = baseColor.r; | |
| colorsArray[i*3+1] = baseColor.g; | |
| colorsArray[i*3+2] = baseColor.b; | |
| } | |
| particleGeometry.attributes.position.needsUpdate = true; | |
| particleGeometry.attributes.color.needsUpdate = true; | |
| // Galaxy Animation (Opacity matches Gesture 4) | |
| galaxyMaterial.opacity = THREE.MathUtils.lerp(galaxyMaterial.opacity, state.galaxyEffectActive ? 0.8 : 0.0, 0.03); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |