Created
January 12, 2026 08:27
-
-
Save luthviar/6406b2a2005152a54f61c0b212930c5c to your computer and use it in GitHub Desktop.
cyberpunk.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Cyberpunk Particle System</title> | |
| <!-- Fonts --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --neon-cyan: #00FFFF; | |
| --neon-blue: #0088FF; | |
| --neon-pink: #FF00FF; | |
| --neon-green: #00FF88; | |
| --neon-yellow: #FFFF00; | |
| --bg-color: #050505; | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: var(--bg-color); | |
| font-family: 'Orbitron', sans-serif; | |
| color: var(--neon-cyan); | |
| user-select: none; | |
| } | |
| /* Vignette & Scanlines */ | |
| #vignette { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| background: radial-gradient(circle, transparent 40%, black 100%); | |
| z-index: 10; | |
| } | |
| #scanlines { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); | |
| background-size: 100% 4px, 6px 100%; | |
| opacity: 0.6; | |
| z-index: 11; | |
| } | |
| /* HUD */ | |
| .hud-panel { | |
| position: fixed; | |
| z-index: 20; | |
| padding: 10px; | |
| font-size: 14px; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| text-shadow: 0 0 5px var(--neon-cyan); | |
| } | |
| #hud-tl { top: 20px; left: 20px; text-align: left; } | |
| #hud-tr { top: 20px; right: 20px; text-align: right; } | |
| #hud-bl { bottom: 20px; left: 20px; text-align: left; } | |
| #hud-br { bottom: 20px; right: 20px; text-align: right; } | |
| .hud-label { | |
| display: block; | |
| font-size: 0.8em; | |
| opacity: 0.7; | |
| margin-bottom: 5px; | |
| } | |
| .hud-value { | |
| font-size: 1.2em; | |
| font-weight: bold; | |
| } | |
| /* Video Input (Hidden) */ | |
| #webcam { | |
| display: none; | |
| transform: scaleX(-1); | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| /* Loading Overlay */ | |
| #loading { | |
| position: fixed; | |
| top: 0; left: 0; width: 100%; height: 100%; | |
| background: #000; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| color: var(--neon-cyan); | |
| font-size: 24px; | |
| z-index: 100; | |
| flex-direction: column; | |
| } | |
| .loader-bar { | |
| width: 200px; | |
| height: 4px; | |
| background: #333; | |
| margin-top: 20px; | |
| position: relative; | |
| } | |
| .loader-progress { | |
| position: absolute; | |
| top: 0; left: 0; height: 100%; | |
| background: var(--neon-cyan); | |
| width: 0%; | |
| transition: width 0.3s; | |
| box-shadow: 0 0 10px var(--neon-cyan); | |
| } | |
| </style> | |
| <!-- Import Maps --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/", | |
| "@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/+esm" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <!-- Visual Overlays --> | |
| <div id="vignette"></div> | |
| <div id="scanlines"></div> | |
| <!-- HUD --> | |
| <div id="hud-tl" class="hud-panel"> | |
| <span class="hud-label">FPS</span> | |
| <span id="fps-counter" class="hud-value">00</span> | |
| </div> | |
| <div id="hud-tr" class="hud-panel"> | |
| <span class="hud-label">Particles</span> | |
| <span id="particle-counter" class="hud-value">12000</span> | |
| </div> | |
| <div id="hud-bl" class="hud-panel"> | |
| <span class="hud-label">L-Hand Status</span> | |
| <span id="l-hand-status" class="hud-value">SEARCHING...</span> | |
| </div> | |
| <div id="hud-br" class="hud-panel"> | |
| <span class="hud-label">R-Hand Status</span> | |
| <span id="r-hand-status" class="hud-value">SEARCHING...</span> | |
| </div> | |
| <!-- Loading Screen --> | |
| <div id="loading"> | |
| <div>INITIALIZING SYSTEM...</div> | |
| <div class="loader-bar"><div class="loader-progress" id="loader-progress"></div></div> | |
| </div> | |
| <!-- Video & Canvas --> | |
| <video id="webcam" playsinline autoplay></video> | |
| <!-- Three.js Canvas will be appended here automatically --> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision'; | |
| // --- Configuration --- | |
| const CONFIG = { | |
| particleCount: 12000, | |
| particleSize: 2.4, | |
| returnSpeed: 0.16, | |
| colors: { | |
| blue: 0x00FFFF, | |
| yellow: 0xFFFF00, | |
| pink: 0xFF00FF, | |
| green: 0x00FF88, | |
| orange: 0xFFA500 | |
| } | |
| }; | |
| // --- Audio System --- | |
| class AudioSynth { | |
| constructor() { | |
| this.ctx = null; | |
| this.master = null; | |
| } | |
| init() { | |
| if (this.ctx) return; | |
| this.ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| this.master = this.ctx.createGain(); | |
| this.master.connect(this.ctx.destination); | |
| this.master.gain.value = 0.4; | |
| // Startup sound | |
| this.playSweep(200, 800, 0.5, 'sine'); | |
| } | |
| playTone(freq, type = 'sine', duration = 0.1) { | |
| if (!this.ctx) return; | |
| const osc = this.ctx.createOscillator(); | |
| const gain = this.ctx.createGain(); | |
| osc.type = type; | |
| osc.frequency.setValueAtTime(freq, this.ctx.currentTime); | |
| // Pitch slide for "tech" feel | |
| osc.frequency.exponentialRampToValueAtTime(freq * 0.8, this.ctx.currentTime + duration); | |
| gain.gain.setValueAtTime(0.3, this.ctx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); | |
| osc.connect(gain); | |
| gain.connect(this.master); | |
| osc.start(); | |
| osc.stop(this.ctx.currentTime + duration); | |
| } | |
| playSoundForState(state) { | |
| if (!this.ctx) return; | |
| switch(state) { | |
| case 'HELLO': this.playTone(440, 'triangle', 0.2); break; | |
| case 'GEMINI': this.playTone(554, 'square', 0.2); break; | |
| case 'USEFUL': this.playTone(659, 'sawtooth', 0.25); break; | |
| case 'BYE': this.playTone(880, 'sine', 0.15); break; | |
| case 'CATCH': this.playGlitch(); break; | |
| case 'NEBULA': this.playSweep(100, 1200, 1.0, 'sawtooth'); break; | |
| } | |
| } | |
| playGlitch() { | |
| if (!this.ctx) return; | |
| const bufferSize = this.ctx.sampleRate * 0.2; // 200ms | |
| const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < bufferSize; i++) { | |
| data[i] = Math.random() * 2 - 1; | |
| } | |
| const noise = this.ctx.createBufferSource(); | |
| noise.buffer = buffer; | |
| const gain = this.ctx.createGain(); | |
| gain.gain.setValueAtTime(0.5, this.ctx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2); | |
| // Filter for "digital" sound | |
| const filter = this.ctx.createBiquadFilter(); | |
| filter.type = 'highpass'; | |
| filter.frequency.value = 1000; | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(this.master); | |
| noise.start(); | |
| } | |
| playSweep(startFreq = 200, endFreq = 2000, duration = 1.0, type='sine') { | |
| if (!this.ctx) return; | |
| const osc = this.ctx.createOscillator(); | |
| const gain = this.ctx.createGain(); | |
| osc.type = type; | |
| osc.frequency.setValueAtTime(startFreq, this.ctx.currentTime); | |
| osc.frequency.exponentialRampToValueAtTime(endFreq, this.ctx.currentTime + duration); | |
| gain.gain.setValueAtTime(0.2, this.ctx.currentTime); | |
| gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration); | |
| osc.connect(gain); | |
| gain.connect(this.master); | |
| osc.start(); | |
| osc.stop(this.ctx.currentTime + duration); | |
| } | |
| } | |
| // --- Text & Coordinate Generator --- | |
| class TextGenerator { | |
| constructor(width, height) { | |
| this.width = width; | |
| this.height = height; | |
| this.canvas = document.createElement('canvas'); | |
| this.canvas.width = width; | |
| this.canvas.height = height; | |
| this.ctx = this.canvas.getContext('2d'); | |
| } | |
| generate(text) { | |
| this.ctx.clearRect(0, 0, this.width, this.height); | |
| this.ctx.font = 'bold 150px Orbitron'; // Scaled for resolution | |
| this.ctx.fillStyle = 'white'; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.textBaseline = 'middle'; | |
| this.ctx.fillText(text, this.width / 2, this.height / 2); | |
| const imageData = this.ctx.getImageData(0, 0, this.width, this.height); | |
| const positions = []; | |
| // Scan pixels - optimize step based on particle neeeds | |
| const step = 4; | |
| for (let y = 0; y < this.height; y += step) { | |
| for (let x = 0; x < this.width; x += step) { | |
| const index = (y * this.width + x) * 4; | |
| if (imageData.data[index] > 128) { | |
| // Map 2D to 3D. Center is (0,0) | |
| const posX = (x - this.width / 2) * 0.1; | |
| const posY = -(y - this.height / 2) * 0.1; // Invert Y | |
| positions.push(posX, posY, 0); | |
| } | |
| } | |
| } | |
| return positions; | |
| } | |
| } | |
| // --- Particle System --- | |
| class ParticleSystem { | |
| constructor(scene, camera) { | |
| this.scene = scene; | |
| this.camera = camera; | |
| this.textGen = new TextGenerator(1024, 512); | |
| // State | |
| this.currentShape = 'TEXT'; // TEXT, SPHERE, NEBULA | |
| this.targetPositions = new Float32Array(CONFIG.particleCount * 3); | |
| // Initialize Geometry | |
| this.geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(CONFIG.particleCount * 3); | |
| const colors = new Float32Array(CONFIG.particleCount * 3); | |
| const sizes = new Float32Array(CONFIG.particleCount); | |
| const color = new THREE.Color(CONFIG.colors.blue); | |
| for(let i=0; i<CONFIG.particleCount; i++) { | |
| // Random start positions | |
| positions[i*3] = (Math.random() - 0.5) * 300; | |
| positions[i*3+1] = (Math.random() - 0.5) * 300; | |
| positions[i*3+2] = (Math.random() - 0.5) * 100; | |
| colors[i*3] = color.r; | |
| colors[i*3+1] = color.g; | |
| colors[i*3+2] = color.b; | |
| sizes[i] = CONFIG.particleSize * (0.5 + Math.random() * 0.5); | |
| } | |
| this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| this.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| this.geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); | |
| // Material - Using shader for better control/glow | |
| const sprite = new THREE.TextureLoader().load('https://threejs.org/examples/textures/sprites/disc.png'); | |
| this.material = new THREE.PointsMaterial({ | |
| size: CONFIG.particleSize, | |
| vertexColors: true, | |
| map: sprite, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| transparent: true, | |
| opacity: 0.9, | |
| sizeAttenuation: true | |
| }); | |
| this.mesh = new THREE.Points(this.geometry, this.material); | |
| this.scene.add(this.mesh); | |
| // Track finger state to prevent spamming | |
| this.lastFingers = -1; | |
| this.lastTrigger = 0; | |
| // Initial Text | |
| this.setTargetText("Hello", CONFIG.colors.blue); | |
| } | |
| update(dt, handData) { | |
| const positions = this.geometry.attributes.position.array; | |
| const pColors = this.geometry.attributes.color.array; | |
| // 1. Determine State | |
| let mode = 'DEFAULT'; | |
| let repulsionPoint = null; | |
| let attractionPoint = null; | |
| // Left Hand Logic (Command) | |
| if (handData && handData.left) { | |
| this.handleLeftHand(handData.left); | |
| } | |
| // Right Hand Logic (Interactor) | |
| if (handData && handData.right) { | |
| if (handData.right.isOpen) { | |
| mode = 'NEBULA'; // Scatter | |
| } else { | |
| // Pointing/Fist: Repulsion | |
| repulsionPoint = handData.right.indexTip; | |
| } | |
| } | |
| // Combo Logic | |
| if (handData && handData.left && handData.left.isOpen && | |
| handData && handData.right && handData.right.isOpen) { | |
| mode = 'BASKETBALL'; | |
| attractionPoint = handData.left.position; | |
| } | |
| // 2. Physics & Position Updates | |
| const time = performance.now() * 0.001; | |
| for(let i=0; i<CONFIG.particleCount; i++) { | |
| const idx = i*3; | |
| let targetX, targetY, targetZ; | |
| // Mode: Nebula -> Scatter + Sine Wave Ripple | |
| if (mode === 'NEBULA') { | |
| // Scatter to fill screen | |
| targetX = positions[idx] + Math.sin(time + i) * 0.5; | |
| targetY = positions[idx+1] + Math.cos(time + i) * 0.5; | |
| targetZ = positions[idx+2] + (Math.random()-0.5) * 2; | |
| // Ripple logic: if hand moves through? | |
| if (handData && handData.right) { | |
| const dx = positions[idx] - handData.right.position.x; | |
| const dy = positions[idx+1] - handData.right.position.y; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| if (dist < 50) { | |
| const ripple = Math.sin(dist * 0.5 - time * 10) * 5; | |
| targetZ += ripple; | |
| } | |
| } | |
| } else if (mode === 'BASKETBALL' && attractionPoint) { | |
| // Golden Spiral Sphere (Fibonacci) | |
| const phi = Math.acos( -1 + ( 2 * i ) / CONFIG.particleCount ); | |
| const theta = Math.sqrt( CONFIG.particleCount * Math.PI ) * phi; | |
| const r = 25; // Radius | |
| const sx = r * Math.sin(phi) * Math.cos(theta + time * 2); | |
| const sy = r * Math.sin(phi) * Math.sin(theta + time * 2); | |
| const sz = r * Math.cos(phi); | |
| // Bouncing visualization (Y-axis sine) | |
| const bounce = Math.abs(Math.sin(time * 5 + i * 0.01)) * 5; | |
| targetX = attractionPoint.x + sx; | |
| targetY = attractionPoint.y + sy + bounce; | |
| targetZ = sz; | |
| // Color: Orange | |
| pColors[idx] = 1.0; | |
| pColors[idx+1] = 0.65; // Orange-ish | |
| pColors[idx+2] = 0.0; | |
| // Black lines? (Modulo logic) | |
| if (Math.abs(sx) < 1 || Math.abs(sy) < 1) { | |
| pColors[idx] = 0; pColors[idx+1]=0; pColors[idx+2]=0; | |
| } | |
| } else { | |
| // DEFAULT: Go to Target (Text) | |
| targetX = this.targetPositions[idx]; | |
| targetY = this.targetPositions[idx+1]; | |
| targetZ = this.targetPositions[idx+2]; | |
| } | |
| // Apply Forces | |
| // Repulsion (Right Hand Point) | |
| if (repulsionPoint && mode === 'DEFAULT') { | |
| const dx = positions[idx] - repulsionPoint.x; | |
| const dy = positions[idx+1] - repulsionPoint.y; | |
| const distSq = dx*dx + dy*dy; // Planar | |
| const radius = 30; // Interaction radius | |
| if (distSq < radius * radius) { | |
| const dist = Math.sqrt(distSq); | |
| const force = (radius - dist) / radius; // 0 to 1 | |
| // Strong snappy force | |
| const angle = Math.atan2(dy, dx); | |
| targetX += Math.cos(angle) * force * 50; | |
| targetY += Math.sin(angle) * force * 50; | |
| } | |
| } | |
| // Integration (Lerp) | |
| const lerp = CONFIG.returnSpeed; | |
| positions[idx] += (targetX - positions[idx]) * lerp; | |
| positions[idx+1] += (targetY - positions[idx+1]) * lerp; | |
| positions[idx+2] += (targetZ - positions[idx+2]) * lerp; | |
| } | |
| this.geometry.attributes.position.needsUpdate = true; | |
| this.geometry.attributes.color.needsUpdate = true; | |
| } | |
| handleLeftHand(hand) { | |
| // Debounce / State Machine to prevent flickering | |
| const now = performance.now(); | |
| if (Math.abs(now - this.lastTrigger) < 500) return; // 500ms debounce | |
| // Check if gesture changed | |
| if (hand.fingers !== this.lastFingers) { | |
| this.lastFingers = hand.fingers; | |
| this.lastTrigger = now; | |
| switch(hand.fingers) { | |
| case 1: | |
| this.setTargetText("Hello", CONFIG.colors.blue); | |
| window.app.audio.playSoundForState('HELLO'); | |
| break; | |
| case 2: | |
| this.setTargetText("Gemini3", CONFIG.colors.yellow); | |
| window.app.audio.playSoundForState('GEMINI'); | |
| break; | |
| case 3: | |
| this.setTargetText("非常好用", CONFIG.colors.pink); | |
| window.app.audio.playSoundForState('USEFUL'); | |
| break; | |
| case 4: | |
| this.setTargetText("再见", CONFIG.colors.green); | |
| window.app.audio.playSoundForState('BYE'); | |
| break; | |
| case 5: | |
| window.app.audio.playSoundForState('CATCH'); | |
| break; | |
| } | |
| } | |
| } | |
| setTargetText(text, colorHex) { | |
| const coords = this.textGen.generate(text); | |
| const color = new THREE.Color(colorHex); | |
| const pColors = this.geometry.attributes.color.array; | |
| // Update targets | |
| for(let i=0; i<CONFIG.particleCount; i++) { | |
| const idx = i*3; | |
| // If we have coords, use them. Else random or center. | |
| if (coords && (i*3) < coords.length) { | |
| this.targetPositions[idx] = coords[i*3]; | |
| this.targetPositions[idx+1] = coords[i*3+1]; | |
| this.targetPositions[idx+2] = coords[i*3+2]; | |
| } else { | |
| // Excess particles: hide or float around? | |
| this.targetPositions[idx] = (Math.random() - 0.5) * 200; | |
| this.targetPositions[idx+1] = (Math.random() - 0.5) * 100; | |
| this.targetPositions[idx+2] = (Math.random() - 2) * 50; // Push back | |
| } | |
| // Colors | |
| pColors[idx] = color.r; | |
| pColors[idx+1] = color.g; | |
| pColors[idx+2] = color.b; | |
| } | |
| this.geometry.attributes.color.needsUpdate = true; | |
| } | |
| } | |
| // --- Hand Tracking --- | |
| class HandTracker { | |
| constructor() { | |
| this.video = document.getElementById('webcam'); | |
| this.landmarker = null; | |
| this.leftHand = null; // { fingers: 0, isOpen: false, position: {x,y,z} } | |
| this.rightHand = null; // { indexTip: {x,y,z}, isOpen: false, position: {x,y,z} } | |
| } | |
| async init() { | |
| const vision = await FilesetResolver.forVisionTasks( | |
| "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/wasm" | |
| ); | |
| this.landmarker = await HandLandmarker.createFromOptions(vision, { | |
| baseOptions: { | |
| modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`, | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| numHands: 2 | |
| }); | |
| await this.setupCamera(); | |
| } | |
| async setupCamera() { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: 1280, height: 720, | |
| facingMode: "user" | |
| } | |
| }); | |
| this.video.srcObject = stream; | |
| return new Promise(resolve => { | |
| this.video.onloadedmetadata = () => { | |
| this.video.play(); | |
| resolve(); | |
| }; | |
| }); | |
| } | |
| detect() { | |
| if (!this.landmarker || this.video.currentTime <= 0) return null; | |
| const results = this.landmarker.detectForVideo(this.video, performance.now()); | |
| this.leftHand = null; | |
| this.rightHand = null; | |
| if (results.landmarks) { | |
| for (const [index, landmarks] of results.landmarks.entries()) { | |
| const handedness = results.handedness[index][0].categoryName; | |
| // Note: MediaPipe "Left" is physically the person's right hand in mirror mode. | |
| // But we want "Left Hand" to be the one on the LEFT side of the screen? | |
| // Actually, standard is: User's Left Hand is "Left" category in MP. | |
| // Let's use CategoryName directly. | |
| // Left Hand (Command) | |
| // Right Hand (Interactor) | |
| const fingers = this.countFingers(landmarks, handedness); | |
| const palmPosition = landmarks[9]; // Middle finger MCP as palm center approx | |
| const handData = { | |
| landmarks: landmarks, | |
| fingers: fingers, | |
| isOpen: fingers === 5, | |
| position: this.toWorldCoords(palmPosition) | |
| }; | |
| if (handedness === "Left") { | |
| this.leftHand = handData; | |
| } else { | |
| this.rightHand = handData; | |
| this.rightHand.indexTip = this.toWorldCoords(landmarks[8]); | |
| // Check for gesture states here if needed, or in main loop | |
| } | |
| } | |
| } | |
| // Update HUD | |
| const lStatus = document.getElementById('l-hand-status'); | |
| const rStatus = document.getElementById('r-hand-status'); | |
| if (lStatus) { | |
| lStatus.innerText = this.leftHand ? | |
| `ACTIVE | FINGERS: ${this.leftHand.fingers}` : "SEARCHING..."; | |
| lStatus.style.color = this.leftHand ? "var(--neon-green)" : "var(--neon-cyan)"; | |
| } | |
| if (rStatus) { | |
| rStatus.innerText = this.rightHand ? | |
| `ACTIVE | ${this.rightHand.isOpen ? 'NEBULA' : 'POINT'}` : "SEARCHING..."; | |
| rStatus.style.color = this.rightHand ? "var(--neon-blue)" : "var(--neon-cyan)"; | |
| } | |
| return { left: this.leftHand, right: this.rightHand }; | |
| } | |
| countFingers(landmarks, handedness) { | |
| let count = 0; | |
| // Thumb: 4, Index: 8, Middle: 12, Ring: 16, Pinky: 20 | |
| // Thumb is tricky. Compare x position of tip (4) vs IP (3) | |
| // For Left hand, thumb tip should be to the Right of IP (larger X) ? No, depends on orientation. | |
| // Simpler check: Distance from Pinky MCP (17) to Thumb Tip (4) | |
| // Better Thumb: Compare X of tip(4) to X of MCP(2). | |
| // Left Hand: Tip.x > MCP.x (if palm facing camera) | |
| // Right Hand: Tip.x < MCP.x | |
| if (handedness === "Left") { | |
| if (landmarks[4].x > landmarks[3].x) count++; | |
| } else { | |
| if (landmarks[4].x < landmarks[3].x) count++; | |
| } | |
| // Fingers: Tip.y < PIP.y (Note: Y is inverted in 3D, but 0 at top in 2D MP) | |
| // In MP 2D: 0 is top. So tip.y < pip.y means extended up. | |
| if (landmarks[8].y < landmarks[6].y) count++; | |
| if (landmarks[12].y < landmarks[10].y) count++; | |
| if (landmarks[16].y < landmarks[14].y) count++; | |
| if (landmarks[20].y < landmarks[18].y) count++; | |
| return count; | |
| } | |
| toWorldCoords(landmark) { | |
| // MP: x [0,1], y [0,1]. (0,0) is Top-Left. | |
| // 3D: (0,0) is Center. Y Up. | |
| // Config | |
| const fov = 75; | |
| const cameraZ = 100; | |
| const aspect = window.innerWidth / window.innerHeight; | |
| // Calculate visible height at Z=0 | |
| const vHeight = 2 * Math.tan((fov * Math.PI / 180) / 2) * cameraZ; | |
| const vWidth = vHeight * aspect; | |
| const x = (landmark.x - 0.5) * vWidth; // X is NOT inverted because of mirror | |
| const y = -(landmark.y - 0.5) * vHeight; // Y Inverted | |
| return { x: -x, y, z: 0 }; // Mirror X Logic: MP x=0 is left. Camera x=-width/2 is left. | |
| // Wait. We are mirroring video with CSS scaleX(-1). | |
| // MP coords: 0 is left edge of VIDEO (which is mirrored). | |
| // Visual left is MP Right (x=1)? No. | |
| // If CSS mirrors: User raises Left hand. | |
| // Camera sees it on Right side of frame. | |
| // MP says x = 0.8 (Right side). | |
| // Screen shows it on Left side (CSS mirror). | |
| // We want 3D object to be at Left side. | |
| // So if x = 0.8, we want it at negative X. | |
| // (0.8 - 0.5) * width = +0.3 * width. We want negative. | |
| // So: -(x - 0.5) | |
| } | |
| } | |
| // --- Main App --- | |
| class App { | |
| constructor() { | |
| this.initThree(); | |
| this.particles = new ParticleSystem(this.scene, this.camera); | |
| this.tracker = new HandTracker(); | |
| this.audio = new AudioSynth(); | |
| this.clock = new THREE.Clock(); | |
| // UI | |
| this.fpsElem = document.getElementById('fps-counter'); | |
| this.loader = document.getElementById('loading'); | |
| this.loaderBar = document.getElementById('loader-progress'); | |
| // Start | |
| this.init(); | |
| } | |
| initThree() { | |
| this.scene = new THREE.Scene(); | |
| // Fog for depth | |
| this.scene.fog = new THREE.FogExp2(0x000000, 0.002); | |
| this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| this.camera.position.z = 100; | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| document.body.appendChild(this.renderer.domElement); | |
| window.addEventListener('resize', () => this.onResize()); | |
| } | |
| async init() { | |
| this.updateLoading(20); | |
| // Audio init on first click | |
| window.addEventListener('click', () => this.audio.init(), { once: true }); | |
| this.updateLoading(40); | |
| await this.tracker.init(); | |
| this.updateLoading(90); | |
| // Hide loader | |
| this.loader.style.opacity = 0; | |
| setTimeout(() => this.loader.remove(), 500); | |
| this.animate(); | |
| } | |
| updateLoading(percent) { | |
| this.loaderBar.style.width = percent + '%'; | |
| } | |
| onResize() { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| const dt = this.clock.getDelta(); | |
| const handData = this.tracker.detect(); | |
| // Update Logic | |
| this.particles.update(dt, handData); | |
| // Update HUD | |
| this.fpsElem.textContent = Math.round(1 / dt); | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| // Boot | |
| new App(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment