feat(visualizer): add multiple visualizers with mouse tracking and beat response
- Add BaseVisualizer class for shared visualizer logic - Add Helix visualizer (DNA helix with rungs, beat compression/bounce) - Add Tunnel visualizer (ring tunnel with depth parallax) - Refactor SphereVisualizer to extend BaseVisualizer - Add mouse tracking to all visualizers (canvas-relative, centered) - Add visualizer switching via logo click (persisted to localStorage) - Add beat-responsive bouncing and compression effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
/**
|
||||
* WebGL Visualizer Scene
|
||||
* Three.js particle system that reacts to audio frequency data
|
||||
* Three.js scene manager with multiple selectable visualizers
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { ParticleSystem } from './particles.js';
|
||||
import { SphereVisualizer } from './particles.js';
|
||||
import { TunnelVisualizer } from './tunnel.js';
|
||||
import { HelixVisualizer } from './helix.js';
|
||||
|
||||
// Available visualizer classes
|
||||
const VISUALIZERS = [
|
||||
SphereVisualizer,
|
||||
TunnelVisualizer,
|
||||
HelixVisualizer
|
||||
];
|
||||
|
||||
export class Visualizer {
|
||||
constructor(canvas, audioManager) {
|
||||
@@ -13,6 +22,14 @@ export class Visualizer {
|
||||
this.running = false;
|
||||
this.time = 0;
|
||||
|
||||
// Mouse tracking (normalized -1 to 1)
|
||||
this.mouse = { x: 0, y: 0 };
|
||||
this.mouseActive = false;
|
||||
|
||||
// Visualizer management
|
||||
this.visualizers = [];
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (!canvas) {
|
||||
console.warn('Visualizer: No canvas provided');
|
||||
return;
|
||||
@@ -44,17 +61,93 @@ export class Visualizer {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// Particles
|
||||
this.particles = new ParticleSystem(5000);
|
||||
this.scene.add(this.particles.mesh);
|
||||
// Initialize all visualizers
|
||||
this.visualizers = VISUALIZERS.map(VisualizerClass => {
|
||||
return new VisualizerClass(this.scene);
|
||||
});
|
||||
|
||||
// Load saved visualizer preference
|
||||
const savedIndex = localStorage.getItem('pivoine-visualizer');
|
||||
if (savedIndex !== null) {
|
||||
this.currentIndex = parseInt(savedIndex, 10) % this.visualizers.length;
|
||||
}
|
||||
|
||||
// Show the current visualizer
|
||||
this.visualizers[this.currentIndex].show();
|
||||
|
||||
// Event listeners
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||
|
||||
// Start animation
|
||||
this.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of visualizer names
|
||||
*/
|
||||
getVisualizerNames() {
|
||||
return VISUALIZERS.map(V => V.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current visualizer name
|
||||
*/
|
||||
getCurrentName() {
|
||||
return VISUALIZERS[this.currentIndex].name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current visualizer index
|
||||
*/
|
||||
getCurrentIndex() {
|
||||
return this.currentIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active visualizer by index
|
||||
*/
|
||||
setVisualizer(index) {
|
||||
if (index < 0 || index >= this.visualizers.length) return;
|
||||
if (index === this.currentIndex) return;
|
||||
|
||||
// Hide current
|
||||
this.visualizers[this.currentIndex].hide();
|
||||
|
||||
// Show new
|
||||
this.currentIndex = index;
|
||||
this.visualizers[this.currentIndex].show();
|
||||
|
||||
// Save preference
|
||||
localStorage.setItem('pivoine-visualizer', index.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle to next visualizer
|
||||
*/
|
||||
nextVisualizer() {
|
||||
const nextIndex = (this.currentIndex + 1) % this.visualizers.length;
|
||||
this.setVisualizer(nextIndex);
|
||||
return this.getCurrentName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle to previous visualizer
|
||||
*/
|
||||
prevVisualizer() {
|
||||
const prevIndex = (this.currentIndex - 1 + this.visualizers.length) % this.visualizers.length;
|
||||
this.setVisualizer(prevIndex);
|
||||
return this.getCurrentName();
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
// Normalize to -1 to 1 relative to canvas
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
this.mouseActive = true;
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (!this.camera || !this.renderer) return;
|
||||
|
||||
@@ -89,8 +182,12 @@ export class Visualizer {
|
||||
bands = this.audioManager.getFrequencyBands();
|
||||
}
|
||||
|
||||
// Update particles with audio data
|
||||
this.particles.update(bands, this.time);
|
||||
// Update current visualizer with audio data and mouse position
|
||||
const currentViz = this.visualizers[this.currentIndex];
|
||||
if (currentViz) {
|
||||
const mouse = this.mouseActive ? this.mouse : { x: 0, y: 0 };
|
||||
currentViz.update(bands, this.time, mouse);
|
||||
}
|
||||
|
||||
// Subtle camera movement
|
||||
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
|
||||
@@ -102,7 +199,7 @@ export class Visualizer {
|
||||
|
||||
destroy() {
|
||||
this.running = false;
|
||||
this.particles?.dispose();
|
||||
this.visualizers.forEach(v => v.dispose());
|
||||
this.renderer?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user